# MDK Docs (/) Open, modular infrastructure for Bitcoin mining at any scale # About MDK (/concepts) ## Introducing MDK MDK, the Mining Development Kit, is an [open-source platform](/support/community/contributing#licensing) that delivers a modern, transparent, and modular infrastructure for Bitcoin mining operations. MDK enables Bitcoin mining operations to start small, scale smoothly, and remain in full control, without lock-in, rewrites, or hidden complexity. ## The problem The Bitcoin mining industry has long been constrained by closed systems, proprietary tooling, and vendor lock-in. MDK changes that. ## The solution MDK delivers a modular mining stack that empowers operators and developers to build, monitor, control, and scale mining operations with full ownership: from a single device to gigawatt-scale facilities — without architectural rewrites. MDK ships three conceptual layers, each containing one or more packages: 1. [Orchestration kernel (Kernel)](#the-orchestration-kernel). 2. [Universal SDK](#the-universal-sdk). 3. [MDK App Toolkit](#mdk-app-toolkit). All three communicate through the **MDK protocol**. Browsers reach the Kernel through the Gateway, the consumer-facing integration boundary your team builds with the SDK; [AI agents](#ai-ready-with-unified-intelligence) reach it instead through the standalone MCP server. MDK ships no authentication of its own at any tier: your team supplies it in the app controllers you write (native MCP tools authenticate at the MCP endpoint instead, not through those controllers). Tying everything together is a **single contract per device type**: the same [`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/workers/README.md#3-mdk-contractjson) is designed to serve the UI (data labels), the orchestrator (validation rules), and AI agents (reasoning context) — today, only the orchestrator's validation rules are actually wired to it; UI and agent tooling don't read it yet. One file, one intended source of truth for three audiences. ### The orchestration kernel [Kernel](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md), the Orchestration Kernel, is distributed as [`@tetherto/mdk-kernel`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md). It's the central coordination engine of MDK and serves as a controller: it knows which devices are online, routes commands to the right place, monitors health, and collects performance data. `@tetherto/mdk-kernel` communicates with Workers, never devices directly, through a standardized language called the **MDK Protocol**, a common set of messages every Worker in the system understands, regardless of the device manufacturer or model behind it. Adding a new device type never impacts `@tetherto/mdk-kernel` thanks to the Worker, a device-specific translator that sits between the Kernel and your hardware: it speaks the MDK Protocol upward, and the device's native API downward. The Kernel is **pull-only**, **device-agnostic**, and **self-healing**. Learn more about the [internal modules, recovery flows, and protocol specs](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#architecture) that back those guarantees. ### The universal SDK `@tetherto/mdk-client` is the universal SDK, a connection library that applications use to talk to `@tetherto/mdk-kernel`. It serves as a universal adapter: handling all the connection details so developers can focus on building their application. - **Node.js today**: `@tetherto/mdk-client` ships as a Node.js package. The transport and protocol are designed to allow future clients in other languages (Python, Go, and others) without changes to Kernel or the protocol itself - **Lazy connection handling**: connects on first use and reconnects on the next call after a failure, with bounded retries on `getStatus()` and an optional connect warm-up. There is no background reconnect loop, and HRPC is the only transport - **No lock-in**: developers bring their own stack and connect via the SDK. No framework requirements ### MDK app toolkit For teams that want to ship fast, the [**MDK App toolkit**](https://github.com/tetherto/mdk/blob/main/docs/concepts/app-toolkit.md) is the optional, batteries-included application layer that sits on top of `@tetherto/mdk-kernel`. It ships in three parts: - **Frontend tools**: a headless state brain ([`@tetherto/mdk-ui-foundation`](/reference/ui)), framework adapters ([`@tetherto/mdk-react-adapter`](https://github.com/tetherto/mdk/blob/main/ui/README.md) for React today), and a production-tested React UI Kit ([`@tetherto/mdk-react-devkit`](https://github.com/tetherto/mdk/blob/main/ui/README.md)) for dashboards. - **Backend tools**: the Gateway itself, a Fastify-specific library handling command proxying and request-level caching, with hooks for custom routes and aggregations. There is no Express adapter today. - **Apps**: an app's `mdk-plugin.json` declares its own routes, and its `ui/` module declares the pages and panels a dashboard mounts. Third parties can publish whole features, routes and UI together, without forking the Gateway. The Gateway is the recommended integration path for applications: it adds HTTP routing, request caching, and serves the apps a stack declares. Nothing above the Gateway is required, though: for lightweight tools, standalone scripts, or CI/CD integrations, [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) can talk to Kernel directly. Some Worker-scoped operations (device provisioning, historical log/stat aggregation) go through a direct Worker connection instead of the usual Kernel round-trip; [when to use which](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md#when-to-use-which-client-path) is covered by the client's own guide. ## Who MDK is for MDK is built for everyone involved in mining Bitcoin: - **Mining operators**: monitor and control fleets with real-time dashboards. Get fleet-wide summaries (total hashrate, power usage, temperature alerts) across all your sites. - **Hardware manufacturers**: integrate new devices by building a Worker and writing one [`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/workers/README.md#3-mdk-contractjson). No involvement from MDK maintainers needed. - **Software developers**: build custom mining applications in any language, or leverage the [MDK App Toolkit](https://github.com/tetherto/mdk/blob/main/docs/concepts/app-toolkit.md)'s frontend and backend tools for rapid development. - **AI/Automation teams**: [connect intelligent agents](#ai-ready-with-unified-intelligence) that can monitor and diagnose device issues autonomously, then act on them once an operator approves the write ## Architecture overview `@tetherto/mdk-kernel` is [the Kernel](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md). [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) is the protocol connector every caller uses to reach it. Above those two layers, the supported development path builds in two levels: - **Gateway**: the [Gateway](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) serves the stack's apps and adds request-level caching and an HTTP interface; each app builds its own `@tetherto/mdk-client` and does its own fleet aggregation. Authenticating callers is left to the app controllers you write. AI agents can drive the fleet over MCP, a standalone [`@tetherto/mdk-mcp`](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) process that derives tools from an app's routes. - **MDK App Toolkit**: sits on top of the Gateway. Adds publishable apps for declarative route extensions and frontend packages ([`@tetherto/mdk-ui-foundation`](/reference/ui), React adapter, React UI kit) for teams building operator dashboards Below the Kernel, **devices are the source of truth**. The actual hardware state is reported by the Worker to `@tetherto/mdk-kernel`, which orchestrates a synchronized view across the fleet. Each layer names its canonical doc in [the MDK stack](/concepts/architecture#the-stack). The [round trip](/concepts/architecture#the-round-trip) traces one command end to end, and the [Workers discovery model](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/architecture.md#discovery-model) covers how Kernel finds Workers across local, same-process, and DHT modes. ## AI-ready with unified intelligence MDK is designed from the ground up for [AI-driven operations](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md). Rather than bolting AI on as an afterthought, intelligence is woven directly into the device definition itself. In addition to the technical schemas, every device's contract file ([`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/workers/README.md#3-mdk-contractjson)) contains: - **Safety rules**: for example, "Outlet temperature > 85°C requires immediate intervention" - **Operational constraints**: limits on command frequency, power thresholds, cooling requirements - **Troubleshooting guides**: if/then recovery steps an AI agent can diagnose against autonomously; the recovery action itself still waits for operator approval, the same as any other write The intent is that an AI agent connecting to MDK wouldn't need a separate knowledge base or custom prompts per device: the same contract that Kernel already validates commands against would also determine how AI reasons about that hardware. That wiring is not built yet: MCP tools today come from a hand-authored manifest or an app's routes, not from a Worker's contract (see [Connecting intelligent agents](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md)). ## What you can build - Operational dashboards (hashrate, power, temperature) - Multisite fleet management with centralized oversight - Alerts and notifications for critical device events - Overheating detection and automated remediation - AI-driven autonomous monitoring, with human-approved control actions - Custom analytics and reporting pipelines - White-labeled hosted mining platforms - Third-party device integrations and apps ## Scaling MDK [scales](/concepts/scalability) naturally without architectural changes: - **More devices?** Add more Workers. Each Worker owns a specific set of devices, and `@tetherto/mdk-kernel` routes commands to the right one automatically. - **More sites?** Each physical site runs its own `@tetherto/mdk-kernel` instance, each behind its own Gateway. No MDK component aggregates across sites: that view is your own application code calling each site's Gateway and merging the results. - **Site isolation**: `@tetherto/mdk-kernel` instances are fully independent. A problem at one site has zero impact on any other. ## Next steps Learn more about: - [Architecture](/concepts/architecture) - [MDK App Toolkit](https://github.com/tetherto/mdk/blob/main/docs/concepts/app-toolkit.md) - [Connecting intelligent agents](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) # Architecture (/concepts/architecture) ## How MDK works MDK is built around a three tier ownership model: - **Kernel is invariant** The Kernel provides small coordination layer every deployment runs unchanged: it routes validated commands to whichever Worker owns a device, and pulls telemetry back. - **Extensions are yours** [Worker Plugins](/reference/worker) wrap a device family; [apps](/guides/gateway/apps) add HTTP routes, aggregation, and auth. Both are code you write, own and publish, isolated from the Kernel and from each other. Nothing above the Gateway is required: a deployment can dispatch commands and pull telemetry with just [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md). - **The UI devkit is optional** The [MDK App Toolkit](https://github.com/tetherto/mdk/blob/main/docs/concepts/app-toolkit.md) is the supported path for teams that want one. ## The round trip ```mermaid flowchart LR subgraph consumers [Consumers] Agent["AI Agent"] Dashboard["Dashboard"] end subgraph gw ["Gateway"] GP["App\n(extension point)"] end MCP["MCP server\n(@tetherto/mdk-mcp)"] K["Kernel"] subgraph wk ["Worker"] WP["Worker plugin\n(extension point)"] end Devices[("Devices")] Agent -->|"MCP"| MCP Dashboard -->|"HTTP"| GP GP <-->|"HRPC"| K MCP <-->|"HRPC"| K K <-->|"HRPC"| WP WP --> Devices style gw fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A style wk fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A ``` One request, traced end to end: an AI agent or a dashboard reaches an app's capability. A dashboard calls the app's HTTP route directly; an [AI agent](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md#ai-agents-and-the-mcp-server) arrives instead through an MCP endpoint, a standalone [`@tetherto/mdk-mcp`](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) process that derives tools from that app's routes (the Gateway doesn't host the MCP itself). Either way the app builds its own [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) and dispatches a command through it. [Kernel](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md) resolves which Worker owns the target device, forwards the command over the Worker's own connection, and relays the result back through the same path: app, then caller. Telemetry travels the same round trip in reverse, on demand: the app's client asks Kernel for a device's telemetry, Kernel forwards that pull to the owning Worker and relays the answer straight back. Kernel also runs its own scheduled telemetry/health pulls on a fixed cadence, independent of any caller: the two are separate triggers into the same path, not one waiting on the other. Both extension points sit at the edges of this trip, never in the middle: a **Worker contract** teaches Kernel about one device family (the contract declares the capability surface; the plugin's handlers implement it), and an **app** teaches the Gateway a new route. Kernel itself never changes. ## The three tiers **[Gateway](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md)**: a container that hosts plugins and exposes them over HTTP. It is the active side of the Kernel connection (it dials Kernel, never the reverse). This is the tier where *user*-level authentication, aggregation, and business logic live. Aggregation here means the cross-Worker queries no single Worker can answer — site hashrate, average temperature, cross-rack efficiency — resolved in controller code, since Kernel computes none of them. Kernel's own allowlist, when configured, gates which *connections* it accepts: a [transport-level check, not a user identity](/concepts/security-boundaries). **[Workers](/reference/worker)**: the integration handlers between physical hardware and Kernel, and the source of truth for that hardware's state. A Worker answers only when Kernel asks (identity, capabilities, telemetry, or a command) and never calls Kernel unprompted. **[Kernel](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md)**: the passive coordination layer. It never initiates contact with a caller; it discovers Workers, routes commands to the one that owns a device, and pulls telemetry and health on its own cadence. It performs no aggregation and stores no telemetry itself. ## How Kernel communicates with a Worker Suppose Kernel needs a temperature reading from a Worker's device. Four separate questions explain how that telemetry reaches Kernel: 1. **Discovery**: Which Worker should Kernel contact? Kernel learns the Worker's [public key](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/architecture.md#discovery-model), which identifies the Worker. 2. **Connection**: How can Kernel reach that Worker? HyperDHT helps locate and establish a connection to the peer (Worker) identified by that key. 3. **Permission**: Does the Worker allow Kernel to call it? The Worker checks the caller's key against its [allowlist](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/README.md#caller-allowlist). 4. **Request**: What does Kernel want the Worker to do? Kernel sends a [telemetry pull](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#mdk-protocol) (in our scenario, the temperature reading request) over HRPC, and the Worker replies. Knowing a public key does not by itself grant a connection or permission to call; the allowlist check happens when connections are established and requests are handled. ## Why HRPC? Gateway to Kernel and Kernel to Worker communications use [Hyperswarm RPC (HRPC)](/reference/glossary#hyperswarm-rpc) to connect known backend peers through the same encrypted, public-key-addressed interface, irrespective of whether they run together or on separate hosts. This suits a site's backend communication: callers identify peers by key instead of addressing public HTTP endpoints. The [HRPC connection model](/reference/glossary#hyperswarm-rpc) explains key addressing and encryption. The deployment network requirements cover bootstrap reachability in every shape, and the [client transport reference](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md#transport-details) explains request/response communication and recovery after a connection drops, including how that differs from replaying a command. ## What's authoritative vs. cached Kernel's own store holds only its Worker registry, device capabilities, and the write-command log: never telemetry. Telemetry is Worker-owned: a Worker persists its own device history, and every telemetry read anywhere above it (Gateway app, dashboard, agent) is a live pull through that chain, not a read from a Kernel-side cache. ## The stack Discover each layer's canonical docs: | Layer | Package | Canonical doc | | --------------- | ---------------------- | ------------------------------------------------------ | | Workers | `@tetherto/mdk-worker-*`, one per vendor | [Device protocol adapters](/reference/worker) | | Worker Runtime | `@tetherto/mdk-worker` | [Worker runtime](https://github.com/tetherto/mdk/blob/main/backend/core/docs/README.md#worker-runtime-tethertomdk-worker) | | Kernel | `@tetherto/mdk-kernel` | [Coordination kernel](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md) | | Client SDK | `@tetherto/mdk-client` | [Protocol connector](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) | | Gateway | `@tetherto/mdk-gateway` | [HTTP surface and app router](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) | | App runtime | `@tetherto/mdk-app-runtime` | [One process per app, and the manifest format](https://github.com/tetherto/mdk/blob/main/backend/core/app-runtime/README.md) | | MCP server | `@tetherto/mdk-mcp` | [Tools for AI agents](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) | | App Toolkit | Frontend packages | [The supported development path](https://github.com/tetherto/mdk/blob/main/docs/concepts/app-toolkit.md) | For the per-package detail in each workspace, read the [core package index](https://github.com/tetherto/mdk/blob/main/backend/core/docs/README.md) and the [UI toolkit index](https://github.com/tetherto/mdk/blob/main/ui/README.md). How many of each a deployment runs is covered by [scalability](/concepts/scalability). ## Next steps - Understand [the integration model](/concepts/the-integration-model): what a Worker Plugin and an app each get to do - Understand [the storage model](/concepts/the-storage-model): where state actually lives as you scale - Understand the [security boundaries](/concepts/security-boundaries): what each tier does and does not authenticate, and what you must build - Understand [what an app is](/concepts/whats-an-app) in MDK terms - Understand [scalability](/concepts/scalability): parallel Workers, parallel Kernels, and what's measured today - Understand the [MDK App Toolkit](https://github.com/tetherto/mdk/blob/main/docs/concepts/app-toolkit.md): the recommended development path from Gateway backend to frontend packages - [Connect an AI agent over MCP](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md#ai-agents-and-the-mcp-server): the endpoint agents reach the fleet through, and the security envelope you supply # Scalability (/concepts/scalability) ## Overview Scale refers to *how many* Workers and Kernels a deployment runs, this includes: - **Devices per Worker instance**: how many devices one running Worker manages. Bounded by the device protocol and the Worker's own connection model, not by Kernel - **Worker instances per Kernel**: how many Worker processes one Kernel coordinates. Kernel places no hard cap; the practical limit is how much command/telemetry traffic one Kernel process can route - **Kernels per Gateway**: today, one. `startGateway()` connects to exactly one Kernel (`kernelKey` is a single value), and `mdk.yaml` declares exactly one Kernel per stack ## Single-kernel versus multi-kernel This page is not about *how those processes are packaged* on a host (one process versus many machines). That's an independent [deployment topology](/guides/deployment) choice. The topology distinction this page does own is, does your deployment run: - One Kernel serving a site? - Several independent Kernels, each serving its own site? A single Kernel process routes commands and telemetry for every Worker registered to it, with no per-Worker partitioning. Each Kernel is paired with its own Gateway (`startGateway()` connects to exactly one Kernel, and `mdk.yaml` declares exactly one Kernel per stack). Add a second Kernel when you're adding a second physically or organizationally distinct site, not to work around a single site's device count. You can run multiple independent sites with one Kernel per physical site (for example, Site A and Site B). Each Kernel is fully isolated: Kernel instances do not federate registries, share queues, or synchronize state with each other, and each runs behind its own Gateway. ```mermaid flowchart TD App["Your application code"] subgraph site_a ["Site A"] GW_A["Gateway"] KERNEL_A["Kernel"] W1_A["Whatsminer Worker"] W2_A["Antminer Worker"] D1_A["Whatsminers"] D2_A["Antminers"] GW_A -->|MDK Protocol via HRPC| KERNEL_A KERNEL_A -->|Routes| W1_A KERNEL_A -->|Routes| W2_A W1_A --- D1_A W2_A --- D2_A end subgraph site_b ["Site B"] GW_B["Gateway"] KERNEL_B["Kernel"] W1_B["Whatsminer Worker"] W2_B["Avalon Worker"] D1_B["Whatsminers"] D2_B["Avalons"] GW_B -->|MDK Protocol via HRPC| KERNEL_B KERNEL_B -->|Routes| W1_B KERNEL_B -->|Routes| W2_B W1_B --- D1_B W2_B --- D2_B end App -->|HTTP| GW_A App -->|HTTP| GW_B style site_a fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A style site_b fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A ``` No MDK component spans sites. Combining them into one view is your own application code calling each site's Gateway separately, each Gateway still talking HRPC to its own Kernel, and merging the responses yourself. ## What parallel Workers and Kernels mean Multiple Workers of the same type (for example, `whatsminer-worker`) can be active concurrently, connected to the same Kernel instance. ```mermaid flowchart TD subgraph kernel ["Single Kernel instance"] Kernel["Kernel"] end W1["Worker 1"] W2["Worker 2"] D1["Devices wm001 to wm500"] D2["Devices wm501 to wm999"] Kernel -->|Routes commands| W1 Kernel -->|Routes commands| W2 W1 --- D1 W2 --- D2 style kernel fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A ``` Workers never share devices: device-to-Worker ownership is a strict, exclusive mapping the registry enforces, so adding Worker instances scales device count linearly with no coordination between them. When Kernel discovers a Worker, its identity response explicitly lists the `deviceId`s it exclusively manages, and the [Worker registry](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#workerregistry) holds that mapping. Kernel routes to whichever Worker owns a `deviceId`; it does not load-balance a device's traffic across multiple Workers, because only one Worker is ever registered as the owner of a given device at a time. ## Where state lives as you grow Each Kernel keeps its own [separate store](/concepts/the-storage-model): a multi-Kernel deployment means multiple independent stores, not one shared or federated one. ## Failure behavior - A single Worker going offline degrades reads/writes for that Worker's devices only: Kernel continues routing to every other registered Worker - A Kernel crash is recovered from its own command write-ahead log on restart: `recover()` settles in-flight commands as `TIMEOUT` (`ERR_COMMAND_UNCONFIRMED`) without re-sending them, since they may already have run. It does not need to reconstruct device state, since it never owned it - In a multi-Kernel deployment, a crash at one site has zero effect on any other, there's no shared state to become inconsistent ## Next steps - Understand [the storage model](/concepts/the-storage-model): what grows with device count, and what doesn't - Choose a [deployment topology](/guides/deployment): how processes are packaged on a host - Understand [architecture](/concepts/architecture): the round trip every command and telemetry pull takes - Read the [Gateway's connection model](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md): how `startGateway()` resolves a single Kernel - Measure a deployment yourself with the [benchmark harness](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/README.md) # Security boundaries (/concepts/security-boundaries) ## TL;DR - **User identity.** MDK does not identify users for you. Your site supplies user authentication; Gateway routes that do not require it remain open to anyone who can reach them. - **Backend access.** Kernel and Workers check backend keys, not human identity. Kernel roles apply to calls through Kernel; direct Worker access is a separate permission without read/write roles. - **Site controls.** Your site owns network access, process isolation, and key permissions, including permissions granted automatically during startup. Assembling a site from UI, Gateway, Kernel, and Workers? The [site security blueprint](/guides/security) provides the steps, options, and suggested defaults. ## Overview This page describes the trust boundary at the Worker tier in depth and where it sits in the Worker → Kernel → Gateway → UI chain. Data flows outward along that chain; a request travels the other way, entering at the Gateway (or, for AI agents, the standalone MCP server). It covers what each layer does and does not authenticate. ## Worker security boundary ### Endpoint identity and key material [`WorkerRuntime`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/README.md) listens over HyperswarmRPC (HRPC) on an encrypted [Noise](https://noiseprotocol.org/) transport, and its HRPC public key identifies and addresses the endpoint. That proves which endpoint a backend peer is talking to: it does **not** establish a human or application identity, grant command permission, or substitute for the application-level authentication you implement above it. A Worker's identity is a pair of seeds, [`seedDht` and `seedRpc`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime.js), from which its DHT and HRPC keypairs are derived. With a `storeDir`, they are written unencrypted as plain files in that directory; with a Hyperbee store, they live unencrypted in the store's `workerConf` space. Anyone who can read them can regenerate the keypair and impersonate the endpoint. Restrict access to the seeds with the same care as a private key: use restrictive file permissions, at-rest encryption where the host requires it, and treat backups as secrets. A DHT topic is only a discovery rendezvous — not a credential or an authorization token. ### Reachability and network controls The `WorkerRuntime` enforces a caller allowlist at connection time and on every request. Unless `allowAll` is enabled, unlisted keys are rejected and an empty list prevents startup. The list has no roles: an admitted key can send any request the Worker supports, including commands. A public key addresses a Worker; it does not grant a caller admission. The Kernel's [HRPC allowlist](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#hrpc-hyperswarm-rpc) and roles apply to requests sent to Kernel. Direct Worker clients pass the Worker's own allowlist instead; Kernel's command controls do not run on that path. A site that requires Kernel-mediated access admits only Kernel's client key on Workers and avoids startup helpers that add other callers. Granting direct access to another process is a separate authorization decision, not a firewall bypass. User-facing Gateway and MCP entry points still need their own authentication and authorization. In addition to caller admission, the surrounding controls are yours: - Restrict direct Worker reachability to trusted backend networks, and apply a host/container firewall policy - Never expose device management interfaces publicly - Treat Worker public keys and DHT topics as deployment configuration, distributed through an authenticated control plane - Run Kernel, Workers, and Gateway as separate processes or containers, so a compromise of one is contained and cannot spread to the others ### Production command path and secrets A host that starts a Worker with `services: null` — the minimal setup — provides no first-party service built-ins and no `write.calls.request` approval integration (MDK's built-in write-approval flow). Direct [`command.request`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#commanddispatcher) dispatch still reaches plugin command handlers regardless, so a production command path must: - Authenticate the requester at the Gateway or control plane - Authorize each device and command, and optionally require approval for high-impact actions - Validate the request again in the handler, and rate-limit it - Write an audit record with actor, target, requested parameters, outcome, and correlation ID The handler context does not currently include actor identity, so actor-level auditing belongs upstream, with handler logs to supplement it. The control-plane security model covers the production trust path. Inject credentials through the host process from a secret manager or protected environment. Pass only the minimum device-specific values in `config`, and never place secrets in `mdk-contract.json`. Redact credentials and device responses from errors, debug logs, telemetry, and audit records. ## Next steps - Follow the [site security blueprint](/guides/security): steps, options, and suggested defaults for a real site - [Understand the Gateway's security model](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md#security-model): where user identity checks are meant to live - Review the control-plane security model: the production trust path for approval-gated writes - [Read the Worker Runtime store services reference](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/README.md#store-services): the full built-in surface `opts.services` can activate # The integration model (/concepts/the-integration-model) ## MDK integration Kernel is extended by building Worker and Gateway capabilities: | | Worker Plugin | App | | --- | --- | --- | | Extends | The Worker tier | The Gateway tier | | Declares itself with | `mdk-contract.json` (the Worker contract) | `mdk-app.yaml`, plus `mdk-plugin.json` for the route table | | That declaration is read by | Kernel today (routing, validation); no other reader exists in this repo today | The app runtime (routes, config) and the Gateway (route table only) | | Job | Speak one device family's native protocol; expose it as telemetry + commands | Add an HTTP route: aggregate, authenticate, or otherwise sit between a caller and `@tetherto/mdk-client` | | Shipped as | A package a stack declares under `spec.workers` | A package a stack declares under `spec.apps`, installable with `mdk add app` | ["Worker Plugin"](/reference/glossary) refers to the **Worker Plugin** package on disk ([`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) plus its handler files. ## `mdk-plugin.json` gets the same treatment, for an app's routes An app's route table declares its routes (`id`, `handler`, `http.method`/`http.path`, response schema, constraints, examples, errors, `safety`). The app runtime reads it to mount routes and validate the manifest shape at load time, and the Gateway reads it to know where to forward; nothing about it is hand-wired into the Gateway's own code path per app. ## Workers are not only hardware A Worker Plugin wraps whatever answers to "one device, one connection, one set of telemetry/commands"; that's just as often a non-hardware integration: - A **pool API** Worker: telemetry is your hashrate/earnings from the pool's own API, "commands" might be switching workers between pools; no physical device involved at all. - An **accounting sync** Worker: telemetry is a ledger balance or a sync status pulled from a third-party service, with no ASIC anywhere in the picture. Both get the exact same treatment from Kernel as a physical miner: identity, capabilities, telemetry pull, command dispatch. Kernel does not know or care that there is no hardware behind either one. ## What this buys you Write the integration once (one Worker Plugin per device family, one app per product surface you ship) and every consumer built against the standard round trip works with it for free: the same dashboard code, the same agent tooling, the same Gateway auth model, regardless of which device family or which route it's actually talking to underneath. ## Next steps - [Build a Worker Plugin](/guides/workers/build-a-worker) for a new device family - Understand the [app authoring flow](/guides/gateway/apps): build it, publish it, install it - Understand [the storage model](/concepts/the-storage-model): what a Worker Plugin decides to persist - Understand [architecture](/concepts/architecture): where both extension points sit in the round trip # The storage model (/concepts/the-storage-model) ## Where data lives Workers own their own telemetry storage. Kernel's Hyperbee holds three things only: the Worker/device registry, published capabilities, and the write-command log. | Data | Lives where | Engine | | --- | --- | --- | | Live device state | The device itself | None | | Worker registry, device capabilities, command log | Kernel's own store | [Hyperbee](https://github.com/holepunchto/hyperbee), via `@tetherto/hp-svc-facs-store` | | Historical telemetry | Each Worker, in its own store | [Worker-defined](/concepts/the-integration-model) | | Credentials and per-device config | Wherever the Worker plugin author put them | [Worker-defined](/concepts/the-integration-model) | ## What's authoritative, and what's cached The physical device is the one source of truth, and no Kernel state stands in for it (Worker health is not device liveness). Everything above it is a view: - A **Worker** is the authoritative record of its own device's state - **Kernel's registry** is authoritative for *routing* (which Worker owns which device), not for device state itself - **Kernel's command log** is authoritative for write-command lifecycle: every state transition (`QUEUED` → `DISPATCHED` → `EXECUTING` → `SUCCESS`/`FAILED`/`TIMEOUT`) is written to a write-ahead log (WAL) before it takes effect, so a crash mid-command recovers cleanly on restart. This WAL guarantee is scoped to that one component: the registry and capability stores next to it are plain Hyperbee, not WAL-backed. - Every telemetry read anywhere above Kernel (an app, a dashboard, an agent) is a live pull through the chain back to the Worker, never a read from a Kernel-side cache. Kernel caches nothing on your behalf. ## Why this model? MDK's storage choices favor **local-first, zero-external-dependency operation** over the query flexibility that alternatives such as a dedicated time-series database or a managed cloud store would give you: a site can run fully offline, with no database server to provision, back up, or pay for beyond the process itself. The [cost](/concepts/scalability) is that cross-device queries (a time range across every miner on a site) are the caller's job, not a stored-procedure or index the platform gives you for free. ## Retention Each store grows for a different reason. Expiring a readable result and reclaiming disk space are separate operations: a deleted key can disappear from the current database view while its backing log retains historical blocks. The settings column names runtime defaults; the last column identifies what an operator should configure or measure. | Store | Holds | Grows with | Bounded by | Setting and default | Start with | | --- | --- | --- | --- | --- | --- | | Kernel `kernel-registry`, `ork-capabilities` | Worker identities, device to contract mappings | Number of Workers and devices | Rewritten only when a device list changes; `terminate()` deletes the entry | None | Nothing to tune | | Kernel `kernel-command-wal` | Every command's lifecycle and terminal result | Command volume | TTL pruning removes terminal keys from the current view on recovery and at most hourly while new commands arrive; it does not establish a disk bound | Manager configuration `kernel.commandResultTtlMs`, 24 h; not forwarded by the factories | Measure physical disk growth separately from the number of readable results | | Worker time logs, legacy surface (`thing-5m-`, `stat-`, `thing-alerts`, `thing-history-log`) | One snapshot per device per interval, per-timeframe stats, alerts, provisioning history | Devices multiplied by snapshot rate | Rotation requires an explicit length threshold; retention cleanup unlinks older segments | `logRotateMaxLength` unset (rotation off); `logKeepCount`, 3; `storeSnapItvMs`, 5 min | Explicitly configure rotation through the [Worker boot options](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md#start-a-worker) and measure retained disk usage | | Worker storage, `WorkerRuntimeV2` | Whatever the Worker Plugin persists | Plugin-defined | Plugin-defined | Plugin-defined | Read the Worker's own README | | Worker seeds (`storeDir`) | The DHT and RPC seeds that fix the Worker's identity | Constant | Constant | None | Back up with the same care as a private key | | Gateway store (`/http`) | Identity seeds | Constant | Constant; response caches are in memory | None | Nothing to tune | | App runtime store (`storeDir`) | The app's RPC and Kernel seeds under `identity/`, and whatever its controllers write to `data/` | One directory per app, plus app-defined data | App-defined | `createAppRuntime({ storeDir })`; `data/` reaches controllers as `config.dataDir` | Without a `storeDir` both keys are random per start and `config.dataDir` is `null` | | App runtime `storeDir/data/` | Whatever the app persists | App-defined | App-defined | App-defined | Read the app's README | The legacy rows describe the thing and adapter surface (`createWorkerInfra`), which every in-repo Worker still uses and which 0.12 deprecates; a `WorkerRuntimeV2` Worker, such as the external Antminer Worker and every new Worker, owns its own storage and its own retention. The [capacity template](/guides/deployment/capacity-metrics-template#storage-breakdown) is where measured growth per store goes. ## Can you swap the backend? Not today. Kernel's stores are Hyperbee via `@tetherto/hp-svc-facs-store` with no alternate-backend interface: a different storage engine is not a supported extension point the way a Worker Plugin or an app is. ## Getting data out There is no built-in export or streaming-to-a-warehouse path today. Anything you need outside of a live pull through an app (a scheduled export, a mirror into another system) is code you write yourself against `@tetherto/mdk-client`, the same way an app controller would. ## Failure A full disk or an unreachable Worker degrades the specific read that touches it: `telemetryCollector.pull()` returns nothing for that device rather than blocking every other request. A Kernel restart replays its command WAL (`recover()` sweeps non-terminal command states) but does not need to reconstruct device state, since it never owned it. Each Kernel instance keeps its own separate store: there is no shared or federated storage across multiple Kernels. A [multi-site deployment](/concepts/scalability) means multiple independent stores, one per site, with no cross-site consistency to reason about. ## Next steps - Understand [the integration model](/concepts/the-integration-model): what a Worker Plugin decides to persist, and how - Understand [architecture](/concepts/architecture): the round trip a read or write actually takes - Understand [scalability](/concepts/scalability): what changes about storage as a fleet grows # What's an app? (/concepts/whats-an-app) ## Definition An MDK app is a publishable package: a route table (`mdk-plugin.json`) with the controllers behind it, plus a UI module, all described by an `mdk-app.yaml` manifest. You scaffold it with `mdk create app`, publish it to npm or GitHub, and anyone installs it into their own stack with `mdk add app`. An app is the unit of work you author and ship. A stack is what an operator runs, and it lists the apps it serves under `spec.apps` in its `mdk.yaml`. ## Anatomy ```mermaid flowchart TB subgraph reused ["MDK: reused unchanged"] Spec["mdk.yaml: the operator's stack"] Kernel["Kernel"] Gateway["Gateway"] Runtime["App runtime"] Workers["Workers"] Shell["Dashboard shell"] end subgraph yours ["Yours: one app package"] Manifest["mdk-app.yaml"] Plugin["plugin/: route table + controllers"] AppUi["ui/: the UI module"] end Spec --> Runtime Manifest --> Runtime Plugin --> Runtime Runtime --> Gateway Gateway --> Kernel Kernel --> Workers AppUi --> Shell Shell --> Gateway style reused fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A ``` What the package holds, as [`mdk create app`](/guides/gateway/apps) scaffolds it: ```text apps/fleet/ ├── mdk-app.yaml # app manifest ├── package.json # `files` ships the manifest, plugin/ and ui/ ├── plugin/ │ ├── mdk-plugin.json # route table │ ├── lib/client.js # the app's own mdk-client │ └── controllers/summary.js # one module per route ├── ui-src/ # UI source └── ui/index.js # the built UI module, committed ``` | Part | What it is | Who reads it | | --------------- | ------------------------------------------------------------------------------- | ------------------------- | | `mdk-app.yaml` | The manifest: `id`, `version`, `displayName`, and optional `description`, `permissions`, `config` and `main` | The app runtime and the operator, at boot and at `mdk validate app` | | `plugin/` | `mdk-plugin.json` declaring the routes, one controller module per route, and the app's own [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) | The app runtime, which serves the routes | | `ui/` | An ES module default-exporting `defineMdkApp({ id, displayName, routes, panels })`, built from `ui-src/` and committed | The dashboard shell, which mounts its pages and panels | | `package.json` | The npm identity, and the `files` list that decides what ships in the tarball | npm, and `mdk add app` | [`backend/apps/demo/`](https://github.com/tetherto/mdk/blob/main/backend/apps/demo/README.md) is a shipped app you can read end to end: its manifest, its route table and one controller per route. It serves routes only, so the `ui/` half is the one part it does not show; `mdk create app` scaffolds that. ## What's yours versus what's reused Yours is the app package and nothing else. Kernel, the Gateway, the app runtime, the Workers your routes read from, and the dashboard shell your UI mounts into are all reused unchanged, in every deployment (see [architecture](/concepts/architecture)). A Worker Plugin is a separate thing you may also own: it integrates one device family, and [the integration model](/concepts/the-integration-model) covers how the two differ. ## What an app is not - Not a stack. `mdk.yaml` is the operator's deployment unit, and it lists apps under `spec.apps` rather than being one. - Not a fork of Kernel or the Gateway. You never edit their source to build an app. - Not a Worker. A Worker speaks a device protocol downward to hardware; an app serves HTTP routes upward to people and agents. - Not loaded by default. A Gateway serves only the apps its stack declares, so installing a package is not the same as serving it. ## Next steps - Understand [the integration model](/concepts/the-integration-model): what a Worker Plugin and an app each get to do - Understand [architecture](/concepts/architecture): how the pieces of a stack talk to each other - Understand the [MDK App Toolkit](https://github.com/tetherto/mdk/blob/main/docs/concepts/app-toolkit.md): the layers a UI or headless consumer builds on - [Build, publish and install an app](/guides/gateway/apps) from an empty directory - Look up [the terms in this page](/reference/glossary) when a word does more than one job # Guides (/guides) ## Security considerations } title="Site security blueprint" href="/guides/security" description="Secure an enterprise MDK site: identity, allowlists, and network isolation" /> ## Get started } title="CLI" href="/guides/cli" description="Install and verify the mdk CLI from a source checkout" /> } title="Run an MDK site" href="/guides/deployment" description="Choose a deployment topology and run a production or single-process MDK site" /> ## Build and extend } title="Gateway" href="/guides/gateway" description="Run, configure, and extend the MDK Gateway" /> } title="Operator agent" href="/guides/agent" description="Run MDK's conversational operator agent, standalone or behind the Gateway" /> } title="UI" href="/guides/ui" description="Compose reporting layouts and wire the React UI Devkit into your app" /> } title="Build a third-party Worker" href="/guides/workers/build-a-worker" description="Integrate your own hardware, firmware, or data feed with MDK by shipping a Worker package from your own repo" /> ## Connect hardware } title="Run containers" href="/guides/containers" description="Connect an Antspace or Bitdeer container to an MDK stack" /> } title="Miner Workers" href="/guides/miners" description="Connect Antminer, Avalon, or Whatsminer hardware to an MDK stack" /> # Operator agent how-to guides (/guides/agent) ## Overview `@tetherto/mdk-agent` is a conversational operator agent that answers plain-language questions about a mining fleet, calls fleet tools over MCP, and gates writes behind human approval. [What the agent is and how it fits the stack](https://github.com/tetherto/mdk/blob/main/backend/core/agent/README.md) covers the concepts; these guides cover running it. The agent never rides on another stack's own Gateway. A model (`:11500`), an MCP tool server (`:3101` in the [MVP site example](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/README.md)), and the agent's own Gateway (`:3847`) are three separate processes The MVP site's own Gateway (`:3000`) has no `/agent` routes at all. ```text model ──► agent gateway ──► MCP tool server ──► kernel + workers :11500 :3847 :3101 (the fleet) ▲ └── UI shell :3030, proxying /agent ``` The agent connects to MCP once, when a session is created (the first `POST /agent/sessions`), so a session started before the tool server is up gets no tools and answers from the model alone, with no error to tell you. ## Choose a guide | Goal | Guide | | --- | --- | | Run the agent as a standalone CLI, for local development | [Run the agent as a standalone CLI](/guides/agent/run-standalone) | | Deploy the agent behind the Gateway as a chat API for an operator UI | [Deploy the agent behind the Gateway](/guides/agent/gateway-deployment) | Exposing an app's own routes to the agent as tools is a detail of [building an app](/guides/gateway/apps), not its own deployment path. See [Expose data to the agent](/guides/agent/expose-data) there. ## Next steps - If Gateway, Kernel, or plugin are unfamiliar, see the [terminology](/reference/glossary) - [Understand the agent as a stack component](https://github.com/tetherto/mdk/blob/main/backend/core/agent/README.md) - [Understand the Gateway as a development surface](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) # Expose data to the agent (/guides/agent/expose-data) ## Overview The operator agent calls fleet data and actions as MCP tools. An app's routes become those tools with no separate MCP manifest to author and keep in sync: the standalone MCP server reads the app's own `mdk-plugin.json` route table — the same file the Gateway reads — and converts each route into a tool. The MCP server is its own process. The Gateway hosts no MCP code and needs no MCP configuration. Neither runs app code: both read the app's route table and forward each call to the app's runtime, so the app runs once, in its own process. This page shows the `mdk.yaml` shape that wires this up, the same way [declaring an app](/guides/gateway/apps) does. It's an addition to your own site config, not a fixture to boot as written: the Kernel, Gateway, and apps are whatever your site already has. Nothing MCP-related goes in your `startGateway()` call — the standalone `mdk run mcp` process reads the same `spec.apps` list the Gateway serves. ## Prerequisites - An [app](/guides/gateway/apps) declared in `spec.apps` - The stack is running — `mdk run` starts the Kernel and the app runtimes the MCP server forwards to. [Kernel's caller allowlist](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) governs whether the MCP server's connection to the Kernel is admitted ### Serve an app's routes as tools Nothing is added to the app. Declare it in `mdk.yaml` as usual and start the MCP server: ```yaml spec: gateway: port: 3000 apps: - name: custom-metrics package: ./apps/custom-metrics mcp: port: 3100 # optional — defaults to gateway.port + 100 ``` ```bash mdk run # first, in its own terminal mdk run mcp # then, in another ``` `mdk run mcp` reads the same `spec.apps` list the Gateway serves, so an app's routes are reachable over HTTP and over MCP without being declared twice. Each route becomes a tool named after its `id` (dots and other non-alphanumeric characters become underscores), with the description, safety hint, and input schema derived from the route's `http` block. Path, query, and header parameters and the `requestBody`'s top-level properties become the tool's input fields. A tool call reaches the same controller, in the same app runtime, as an HTTP request to the route, with `spec.mcp.identity` as the caller. Until `mdk run` has started the app, its tools return `ERR_APP_RUNTIME_UNAVAILABLE`. Only routes marked `"safety": "read-only"` become tools by default. Every other route is withheld as a write unless the server is started with `--allow-write` (or `spec.mcp.allowWrite`), or the tool is named in `spec.mcp.allowTools`. `mcp` is never part of the default `all` target; it is always started explicitly. ### Write tools by hand instead An app that needs a different tool granularity, richer descriptions, or direct `mdkClient` calls can author an `mcp-plugin.json` by hand. A standalone MCP server serves both kinds at once — hand-authored tool plugins alongside app-derived ones — so a curated tool set and auto-derived routes can live on one endpoint. Tool ids must be unique across both sources; a collision is a startup error, not a silent override. Serving hand-authored tools this way means embedding the standalone server through `createMcpServer()`, as the `@tetherto/mdk-mcp` README shows. `mdk run mcp` reads no `spec.mcp.plugins` list yet, so from `mdk.yaml` alone it serves only the app-derived tools above. ## Next steps - [Build the app whose routes you want to expose](/guides/gateway/apps) - [Enable the operator agent](/guides/agent/gateway-deployment) to call the tools this produces - [Understand the agent as a stack component](https://github.com/tetherto/mdk/blob/main/backend/core/agent/README.md) # Deploy the agent behind the Gateway (/guides/agent/gateway-deployment) ## Overview [`@tetherto/mdk-app-agent`](https://github.com/tetherto/mdk/blob/main/backend/apps/agent/README.md) mounts [`@tetherto/mdk-agent`](https://github.com/tetherto/mdk/blob/main/backend/core/agent/README.md) behind the Gateway as a chat API. Enable the app for session, message, and approval routes: every write the agent proposes pauses for an operator's decision. The agent itself still reaches fleet data the way [any AI agent does](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md#ai-agents-and-the-mcp-server), over an MCP server reachable at `agent.mcp.url` — the standalone `@tetherto/mdk-mcp` process; this app gives a human operator a chat surface to talk to it through. This is one of [two ways to run the agent](/guides/agent). If you've already run it [standalone](/guides/agent/run-standalone), it's the same agent with HTTP on it, not a second product: the same provider, the same MCP client, the same approval gate, just reached over sessions instead of a REPL. ## Prerequisites - The [Gateway is running](/guides/gateway/run) - A model is [served on this machine, or reachable on another](/guides/agent/run-standalone#serve-the-model-with-qvac), because the app dials the provider rather than starting one - An [MCP tool server](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) is reachable, so the agent has fleet tools to call The Gateway never starts a model. `config.agent.provider` is a URL it dials, so a first session against a `baseURL` with nothing behind it fails with `ERR_AGENT_UNAVAILABLE`. [Serving a model locally with QVAC](/guides/agent/run-standalone#serve-the-model-with-qvac) covers the install, the first-run download, and the flags a lazily loaded model needs. ### Mount the app Either: - [Select it during onboarding](#11-a-select-it-during-onboarding), or - [Already have an `mdk.yaml`? Add it by hand](#11-b-add-it-to-an-existing-mdkyaml) #### 1.1 A. Select it during onboarding Run [`mdk onboard`](/guides/cli/install#command-groups) and select [`mdk-app-agent`](https://github.com/tetherto/mdk/blob/main/backend/apps/agent/README.md) from its app catalog. `mdk onboard`'s wizard supports creating a compliant `mdk.yaml` from scratch, interactively. Its entry carries a real `repoPath` ([`backend/apps/agent`](https://github.com/tetherto/mdk/blob/main/backend/apps/agent/README.md)), not a stub, so selecting it installs a working app rather than a placeholder, and the wizard writes its `spec.apps` entry with `mount: /`. #### 1.1 B. Add it to an existing `mdk.yaml` Already have one from a previous onboarding run? Add the app under `spec.apps` by hand, with `mount: /` and the model provider, the MCP url, and the approval timeout under `agent`. Once published, that directory is `node_modules/@tetherto/mdk-app-agent`; in this monorepo checkout it is [`backend/apps/agent`](https://github.com/tetherto/mdk/blob/main/backend/apps/agent/README.md). For example, a standalone gateway carrying just this app, reaching across to the [MVP site example](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/README.md)'s MCP tool server: ```yaml apiVersion: mdk/v1 kind: Stack metadata: name: agent-gateway spec: workers: [] gateway: port: 3847 apps: - name: agent package: "@tetherto/mdk-app-agent" mount: / config: agent: provider: { kind: qvac, model: qwen3-4b, baseURL: http://127.0.0.1:11500/v1 } mcp: { url: http://127.0.0.1:3101/mcp } approvalTimeoutMs: 120000 ``` Keep `mount: /`. The [agent UI](https://github.com/tetherto/mdk/blob/main/ui/packages/ui-agent/README.md) calls `/agent/sessions…` at the Gateway root; without it the routes live under `/apps/agent/agent/sessions…` and the UI gets `404`. #### 1.2 Run it Once the `mdk.yaml` names the app, either way, run `mdk run`. It starts the app in its own runtime process and the Gateway forwards the agent routes to it. A stack built for just this app declares no Workers, so `mdk run` boots a Kernel it never actually uses (it only proxies chat to the MVP site's separate MCP server) alongside the app and the Gateway. The agent routes are `auth: true`. Unless `gateway.config.authVerifier` names another verifier, `mdk run` uses the built-in `loopback` one: a caller on the same machine is the single `local` operator and gets the full chat and approval flow with no identity setup, while a remote caller gets `401`. Serving operators on other machines needs a real verifier (Gateway security model). A missing [`config.agent`](https://github.com/tetherto/mdk/blob/main/backend/apps/agent/README.md#configuration) block answers `503 ERR_AGENT_UNAVAILABLE` instead of failing to load. ### Create a session and send a message Use the port your `mdk.yaml` gave the gateway: `3847` in the example above. Note that the [MVP site](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/README.md)'s own gateway (`3000`) never carries the agent app; this is a separate gateway, reaching across to the MVP site's MCP tool server on `3101`. ```bash curl -X POST http://localhost:/agent/sessions # {"sessionId":"..."} curl -N -X POST http://localhost:/agent/sessions//messages \ -H 'Content-Type: application/json' \ -d '{"text":"how many miners are on the site?"}' ``` The response streams as `text/event-stream`. A read-only question ends in `tool_call`, `tool_result`, `token`, and `done` events, each stamped with the turn's `turnId` and a monotonic `seq`. ### Approve a write A write action pauses the turn instead of running it: ```text event: pending_approval data: {"type":"pending_approval","name":"act_device","args":{"ref":"whatsminer-0","action":"reboot"},"approvalId":"..."} ``` Decide it from the paused stream's `approvalId`: ```bash curl -X POST http://localhost:/agent/sessions//approvals/ \ -H 'Content-Type: application/json' \ -d '{"approved":true}' ``` Approving resumes the same stream: the tool runs for real, and the turn continues to its `token` and `done` events. Rejecting, or letting the approval window expire, resolves to false, and the write never runs. ## Troubleshooting - **Sessions and messages work, but the agent never calls a tool.** `agent.mcp` (or its `url`) is missing from the config — [how to fix it](https://github.com/tetherto/mdk/blob/main/backend/apps/agent/README.md#troubleshooting) is in the app's troubleshooting entry - **The agent routes answer `404`.** The app entry has no `mount: /`, so its routes are served under `/apps/`. Add `mount: /` to its `spec.apps` entry - Every other failure (`503`, `404`, `409`, `400`) maps to a specific cause and fix in [the app's error reference](https://github.com/tetherto/mdk/blob/main/backend/apps/agent/README.md#errors) - **`npm ci` fails to resolve a stack that mounts only this app.** `@tetherto/mdk-app-agent` declares `@tetherto/mdk-agent` as a required dependency, not an optional peer, so the agent package must be installed alongside it for `npm ci` to resolve. ## Next steps - [Read the agent app's route reference](https://github.com/tetherto/mdk/blob/main/backend/apps/agent/README.md): session, message, and approval routes, plus the manifest's `setup` fields - [Understand the underlying agent](https://github.com/tetherto/mdk/blob/main/backend/core/agent/README.md): the model and its fleet tools - [Submit and approve write actions](/guides/gateway/write-actions) from a React app, for the UI-driven shape of this same approval gate # Run the agent as a standalone CLI (/guides/agent/run-standalone) ## Overview [`@tetherto/mdk-agent`](https://github.com/tetherto/mdk/blob/main/backend/core/agent/README.md) runs as a small library and CLI for local development, talking to a served model and an MCP tool server directly, with no Gateway in front of it. This is one of [two ways to run the agent](/guides/agent). ## Prerequisites - Node.js ≥ 24 - npm 11 [(< 12)](/reference/environment) - GPU — see [the agent's prerequisites](https://github.com/tetherto/mdk/blob/main/backend/core/agent/README.md#prerequisites) for the exact backend requirements - Dependencies installed — see [the agent's prerequisites](https://github.com/tetherto/mdk/blob/main/backend/core/agent/README.md#prerequisites): a single root `npm install`, not one run inside the package - Language model: this guide uses [QVAC](https://qvac.tether.io/) serving an OpenAI-compatible API ### Serve the model with QVAC The agent needs a language model listening locally. Already have a QVAC server running elsewhere? Skip this step, then add `--base-url ` when you [start the agent](#start-the-agent-and-ask). #### 1.1 Note the agent package's path ```bash cd backend/core/agent # From the repo root (dependencies come from the root npm install) pwd # Note this: the serve command below needs it after it cd's elsewhere ``` #### 1.2 Serve the model QVAC installs into a separate directory, so a fresh terminal, started from wherever you like: ```bash mkdir -p ~/qvac-runtime && cd ~/qvac-runtime && npm init -y # once npm i @qvac/cli@^0.12.0 # ~6 GB of prebuilt engines ./node_modules/.bin/qvac doctor # confirms this host can serve # --config pins the model: Qwen3-4B, 4-bit quantized, 16k context ./node_modules/.bin/qvac serve openai \ --config /qvac-runtime/qvac.config.json \ --port 11500 --verbose --no-cancel-load-on-disconnect # --verbose: load + GPU offload; wait until listening on 11500 ```
Why install @qvac/cli outside this package? `@qvac/cli` is an optional peer dependency because the agent only needs it to *serve* a model, not to talk to one. Installing its ~6 GB into this tree perturbs a `package-lock.json` that CI gates on. External mode never loads `@qvac/cli` at all, so a separate directory costs nothing and keeps the lockfile clean.
The port and flag above aren't arbitrary: `11500` avoids Ollama's default `11434` (whichever starts second on a machine with both dies on `EADDRINUSE`), and `--no-cancel-load-on-disconnect` stops a lazily loaded model's first request from failing with a `503` that isn't really about a disconnect. If this doesn't work first try, see [troubleshooting](#troubleshooting).
### Optional: Start the demo fleet If you already have an MCP tool server, skip this and point the agent at its URL in the [next step](#start-the-agent-and-ask). To try it end-to-end, boot the [MVP site example](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/README.md). It brings up a simulated fleet (miners, a powermeter, a pool) under PM2 and exposes the MDK tools over MCP on port `3101`: ```bash cd ../../../examples/mvp-site npm run setup # first time only. NOT npm install: examples/mvp-site is a root workspace member, # and this also builds the UI toolkit and installs its own nested ui/ app npm run setup:config # first time only: writes config/devices.json and config/site.deploy.json npm start # kernel + workers + gateway + MCP server on :3101, under PM2 ``` Leave it running. ### Start the agent and ask In a third terminal, start the CLI — pointing it at the model (per the previous step) and the MCP tool server: ```bash node bin/mdk-agent.js --model qwen3-4b --mcp-url http://127.0.0.1:3101/mcp ``` You may now query the site in plain language, for example: ```text you › how many miners are on the site? → tool count_devices({"family":"miner","state":"all"}) ← data { "summary": "30 miners.", "count": 30 } ▌ There are 30 miners on the site. you › list the devices that are not ready you › reboot antminer-3 ← a write: the agent stops and asks you to approve ``` REPL commands: `/about` (what this is) · `/tools` · `/info` · `/new` · `/exit`.
## Troubleshooting ### `npx qvac` fails with a 404 There is no `qvac` package on npm; the binary comes from `@qvac/cli`, installed in [Serve the model](#serve-the-model-with-qvac). ### `qvac serve openai` dies with `EADDRINUSE` `qvac serve openai` defaults to port `11434`, the same as Ollama's. Pass `--port 11500` (as the command above does) if both run on this machine. ### The first request 503s with `model_load_failed`, blaming a disconnect that didn't happen `serve.load.cancelOnDisconnect` defaults to `true`, which cancels a lazily loaded model's first request. `--no-cancel-load-on-disconnect` fixes it. Models declared `preload: true` (like `qwen3-4b` in `qvac-runtime/qvac.config.json`) never hit this, but the flag costs nothing. ### The first run looks stalled, or the server "never starts" The first run fetches ~2.5 GB peer-to-peer. The log says `registry://s3/…`, but the transfer is actually Hyperswarm (a DHT plus UDP hole-punching), at roughly ~110 MB/min, so ~25 minutes. There's no HTTP fallback, so a network that blocks UDP stalls it, and since `preload: true` means the port doesn't open until the model is resident, a stalled download presents as a dead server, not a failed fetch. Three things help: 1. Copy `qvac.config.json` and set `preload: false`. The port opens in ~5 s and a stalled fetch surfaces as a `503` on the first request instead of a server that looks dead. Switch back once `~/.qvac/models` holds the weights. 2. Always pass `--verbose`; without it there's no progress output at all. 3. Bypass the registry entirely: `serve.models` accepts an explicit `{ "src", "type" }` entry, and `*ModelSrc` fields accept URLs and filesystem paths, so a GGUF fetched by hand over HTTPS, or copied from a machine that already has one, works. This is the path for a locked-down site. ### Disk usage is larger than expected Budget ~6 GB for the CLI, 2.5 GB for the model, and a KV cache that grows. Of that 6 GB, ~5 GB is prebuilt binaries for platforms this host can't run (every modality engine is pulled whether used or not). Deleting the foreign `prebuilds/` directories takes the install to ~400 MB and is safe, verified by cold restart, but `npm i` restores them, so it's disk relief rather than a fix. ### The fleet has fewer devices than expected Each Worker seeds its device list once, into a persistent on-disk store, and only when that store is empty. A later `npm start` reuses whatever's already there rather than reseeding from the current `config/devices.json`, so a fleet from an earlier run (a different device list, or an interrupted seed) sticks around. Run `npm run stop:pm2`, then clear `examples/mvp-site/.site-data` for a fresh fleet on the next start. ## Next steps - [Look up a flag, the capability table, or a hosted-model setup](https://github.com/tetherto/mdk/blob/main/backend/core/agent/README.md): the CLI's full reference - [Deploy the agent behind the Gateway instead](/guides/agent/gateway-deployment), for a chat API an operator UI can call # Install the CLI (/guides/cli) ## TL;DR - Operate the stack from your terminal - Install `mdk` on your PATH with `npm install -g @tetherto/mdk-cli` - Confirm it with `mdk --version` ## Overview `mdk` is MDK's command-line tool for standing up and operating a stack from your terminal. It scaffolds a stack (`mdk create`), boots it (`mdk run`), and reports its health (`mdk status`), all driven by the `mdk.yaml` spec `mdk onboard` writes for you. ## Prerequisites - [Node.js](https://nodejs.org/) >=24 (LTS) - npm 11 [(< 12)](/reference/environment) ## Install Install the published CLI from npm. A source checkout is only needed to work on the CLI itself. ### Put `mdk` on your PATH If `mdk --version` already prints a version, skip to verifying your install. #### A Install from npm ```bash npm install -g @tetherto/mdk-cli ``` `npx @tetherto/mdk-cli@latest ` runs a single command without installing anything globally, which suits CI and one-off scaffolding. #### B Install from a source checkout Contributors who are changing the CLI's own source build it and link that build onto their PATH instead. ```bash git clone https://github.com/tetherto/mdk.git && cd mdk/packages npm install cd cli && npm run build && npm link ``` To pin a tagged release, pick a tag from [mdk tags](https://github.com/tetherto/mdk/tags) and check it out before installing, for example `git checkout -b local/v0.12.1 v0.12.1`. ### Verify the install ```bash mdk --version ``` ### Run project commands from your project Commands such as `mdk skill add` and `mdk onboard` write into your current directory, so run them from the app or UI project you are working in. Only `mdk --version` is global. ## Command groups A selection of commands you can run today: | Group | Commands | | ----------------- | ----------------------------------------| | Onboarding | `mdk onboard` | | Scaffold | `mdk create worker\|app\|dashboard` | | Add & validate | `mdk add app\|worker `, `mdk validate app\|worker ` | | Run & manage | `mdk run [target]`, `mdk status` | | Agent enablement | `mdk skill add` | | Meta | `mdk version` | The [CLI's command surface](https://github.com/tetherto/mdk/blob/main/packages/cli/README.md#command-surface) documents the full flag reference. ## Update ```bash npm install -g @tetherto/mdk-cli@latest ``` ## Uninstall ```bash npm rm -g @tetherto/mdk-cli ``` ## Next steps - Try the [demo](/tutorials/run-a-site) to see a stack running end to end - [Build with your agent](/guides/ui/agent-skills) so a coding agent knows MDK conventions # Run a container Worker (/guides/containers) ## Overview MDK drives each container system through its own Worker. These guides are task-focused and independent, you only need the one for the hardware you operate. If Kernel, Worker, manager, or thing are unfamiliar, read [terminology](/reference/glossary) first. ## Pick your hardware The authoritative model list for every Worker is the generated [supported-hardware catalogue](/reference/supported-hardware). Covered so far: - [Run a Bitdeer Worker](/guides/containers/run-bitdeer-worker) - [Run an Antspace Worker](/guides/containers/run-antspace-worker) ## Prerequisites Every guide assumes: - [Node.js](https://nodejs.org/) >=24 (LTS) - npm 11 [(< 12)](/reference/environment) - Dependencies installed (`npm run setup` from the repo root) - Commands are run from the repo root - Outbound UDP access to the HyperDHT bootstrap nodes For the mock or development path: - No physical container is required - The runnable example for your model starts the bundled mock and registers it HRPC relies on HyperDHT for peer connectivity. Use the [network requirements and checks](/guides/miners/troubleshooting) if an example stalls before printing the Kernel key. For the deployment path: - A Node.js service or script in your deployment that runs the MDK Worker and registers devices - A supported container system reachable from the machine or container running the Worker - The Worker's README for the exact `registerThing` options ## Next steps - Browse [supported hardware](/reference/supported-hardware) - New to the moving parts? Read [terminology](/reference/glossary) (Kernel, Worker, manager, thing, mock) - If an example does not start or a mock port is busy, use [miner troubleshooting](/guides/miners/troubleshooting), the same HRPC and DHT checks apply - Drive the registered device from a dashboard: [run a mining site end to end](/tutorials/run-a-site) # Run an Antspace Worker (/guides/containers/run-antspace-worker) ## Overview This page details how to run the Bitmain Antspace container Worker. Select the development (mock) or real-container path. ## Prerequisites - Review the [common deployment prerequisites](/guides/containers#prerequisites) before you start Deployment-specific requirements: - A Node.js service or script in your deployment that runs the MDK Worker and registers containers - A supported Antspace container reachable from the machine or container running the Worker over its REST HTTP API, typically port `8000` ### Development
Run against a mock The Antspace Worker ships a mock container that answers its HTTP API with canned data. Run the mock in one terminal and the Worker in a second, both from the repository root. 1. Start a mock HK3 container: ```bash npm run mock hk3 18001 ``` 2. Save this script as `antspace-dev.js` in the repository root. It is the boot call from Connect a container, pointed at `127.0.0.1`: ```js 'use strict' const os = require('os') const path = require('path') const { getKernel } = require('@tetherto/mdk-core') const { startAntspaceWorker } = require('@tetherto/mdk-worker-antspace') const main = async () => { const kernel = await getKernel() const worker = await startAntspaceWorker({ workerId: 'antspace-dev', model: 'hk3', storeDir: path.join(os.tmpdir(), 'mdk-dev', 'antspace'), allowedKeys: [kernel.getClientPublicKey()], seedDevices: [{ info: { serialNum: 'HK3-DEV', container: 'dev' }, opts: { address: '127.0.0.1', port: 18001 } }] }) await kernel.registerWorker(worker.runtime.getPublicKey()) console.log('Kernel HRPC key:', kernel.getPublicKey().toString('hex')) console.log('Device:', worker.services.provisioning.listDeviceIds()[0]) console.log('Ctrl+C to stop.') } main().catch((err) => { console.error(err); process.exit(1) }) ``` 3. Run the script in a second terminal: ```bash node antspace-dev.js ``` Expected output, with your own key and device ID: ```text Kernel HRPC key: de61f30cd7bd65923d9c8089e4fa684335a37371600e1610823a4fc20aba0247 Device: 183ded12-bf17-4b40-9970-5c535fd79916 Ctrl+C to stop. ``` The Kernel then pulls live telemetry (temperatures, cooling status, power) from the mock through the Worker. The mock's model and port options are in [the Antspace README](https://github.com/tetherto/mdk/blob/main/backend/workers/containers/antspace/README.md).
### Connect a container #### 2.1 Pick your model Use [the Antspace README](https://github.com/tetherto/mdk/blob/main/backend/workers/containers/antspace/README.md) to confirm the `model` value for your container: `hk3` or `immersion`. This guide uses `hk3`, replace it with the value for your container. #### 2.2 Register your container Add this code to the Node.js service or script that runs the MDK Worker in your deployment. The snippet shows the minimum boot call seeding one Antspace container, replace the example address and credentials with your container's values: ```js const { getKernel } = require('@tetherto/mdk-core') const { startAntspaceWorker } = require('@tetherto/mdk-worker-antspace') const kernel = await getKernel() const worker = await startAntspaceWorker({ workerId: 'antspace-rack-1', model: 'hk3', storeDir: './store/antspace-rack-1', allowedKeys: [kernel.getClientPublicKey()], seedDevices: [{ info: { serialNum: 'HK3-A', container: 'container-A', location: 'site-a-01.container' }, opts: { address: '192.168.1.100', port: 18001 } }] }) await kernel.registerWorker(worker.runtime.getPublicKey()) ``` `allowedKeys` lists the RPC callers the Worker accepts. The Worker refuses to start without it (`ERR_WORKER_ALLOWED_KEYS_REQUIRED`), so pass the Kernel's client key. Make sure each container's address is reachable from the machine or container running the Worker before registering. Commands affect live cooling for racks of miners, prioritize thermal safety. `seedDevices` only seeds a fresh, empty `storeDir`, once persisted, the device set survives restarts on its own. To add a container to an already-running fleet, send the `registerThing` command to the live Worker instead: ```js const { createMdkClient } = require('@tetherto/mdk-client') const client = createMdkClient({ kernelKey: kernel.getPublicKey() }) await client.connect() await client.sendWorkerCommand('antspace-rack-1', null, 'registerThing', { id: 'HK3-B', info: { serialNum: 'HK3-B', container: 'container-A' }, opts: { address: '192.168.1.101', port: 18001 } }) ``` `registerThing` persists the container config immediately, but the running Worker does not pick it up until it is stopped and restarted (`await worker.stop()`, then call `startAntspaceWorker` again with the same `storeDir` and no `seedDevices`), there is no hot-add. For the full `seedDevices` and `registerThing` option reference, the telemetry and command tables, and the shared install pattern, see [the Antspace README](https://github.com/tetherto/mdk/blob/main/backend/workers/containers/antspace/README.md) and [install pattern](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/install-pattern.md).
## Troubleshooting A working development run of `antspace-dev.js` prints the Kernel HRPC key and the registered device ID, then stays running until Ctrl+C. If it does not print those values, or if `npm run mock` reports port `18001` already in use, the network and port checks in [miner troubleshooting](/guides/miners/troubleshooting) apply here too, the underlying HRPC and DHT requirements are the same across every Worker. ## Next steps - Decide how to run the Worker service, [Deployment topologies](/guides/deployment) - Review telemetry units, command shapes, and error codes, [the Antspace README](https://github.com/tetherto/mdk/blob/main/backend/workers/containers/antspace/README.md) # Run a Bitdeer Worker (/guides/containers/run-bitdeer-worker) ## Overview This page details how to run the Bitdeer D40 container Worker. Select the development (mock) or real-container path. The Bitdeer D40 speaks MQTT, and the Worker embeds the broker (one per Worker process) that a container publishes into, rather than the Worker connecting out to the container. Device specs are keyed by `containerId`, not by an address and port. ## Prerequisites - Review the [common deployment prerequisites](/guides/containers#prerequisites) before you start Deployment-specific requirements: - A Node.js service or script in your deployment that runs the MDK Worker and registers containers - A supported D40 container configured to publish into the Worker's embedded MQTT broker, reachable on that broker's port (default `10883`) ### Development
Run against a mock The Bitdeer Worker ships a mock D40 container that publishes canned telemetry as an MQTT client. The Worker hosts the broker, so start the Worker first and the mock second, both from the repository root. 1. Save this script as `bitdeer-dev.js` in the repository root. It is the boot call from Connect a container, with the container ID the mock publishes under: ```js 'use strict' const os = require('os') const path = require('path') const { getKernel } = require('@tetherto/mdk-core') const { startBitdeerWorker } = require('@tetherto/mdk-worker-bitdeer') const main = async () => { const kernel = await getKernel() const worker = await startBitdeerWorker({ workerId: 'bitdeer-dev', model: 'm56', storeDir: path.join(os.tmpdir(), 'mdk-dev', 'bitdeer'), mqttPort: 10883, allowedKeys: [kernel.getClientPublicKey()], seedDevices: [{ info: { serialNum: 'D40-DEV', container: 'dev' }, opts: { containerId: 'D40-DEV' } }] }) await kernel.registerWorker(worker.runtime.getPublicKey()) console.log('Kernel HRPC key:', kernel.getPublicKey().toString('hex')) console.log('Device:', worker.services.provisioning.listDeviceIds()[0]) console.log('Ctrl+C to stop.') } main().catch((err) => { console.error(err); process.exit(1) }) ``` 2. Run the script: ```bash node bitdeer-dev.js ``` 3. Once it prints `Device:`, start a mock D40 M56 in a second terminal, pointed at the Worker's broker port and using the same container ID: ```bash npm run mock d40_m56 10883 id=D40-DEV ``` Expected output from `bitdeer-dev.js`, with your own key and device ID: ```text Kernel HRPC key: a94550e7f07fc2a1ebe38aa1cd482664c164d1d751f6470966611cdc40c65906 Device: 815d7e65-39a4-4d13-938c-ebafb3b730e5 Ctrl+C to stop. ``` Once the mock connects, the Kernel pulls live telemetry (container power, temperature, per-phase data) through the Worker. The mock's model and container ID options are in [the Bitdeer README](https://github.com/tetherto/mdk/blob/main/backend/workers/containers/bitdeer/README.md).
### Connect a container #### 2.1 Pick your model Use [the Bitdeer README](https://github.com/tetherto/mdk/blob/main/backend/workers/containers/bitdeer/README.md) to confirm the `model` value for your D40 variant: `a1346`, `m30`, `m56`, or `s19xp`. This guide uses `m56`, replace it with the value for your container. #### 2.2 Register your container Add this code to the Node.js service or script that runs the MDK Worker in your deployment. The snippet shows the minimum boot call seeding one D40 container, replace the example container ID with your container's value: ```js const { getKernel } = require('@tetherto/mdk-core') const { startBitdeerWorker } = require('@tetherto/mdk-worker-bitdeer') const kernel = await getKernel() const worker = await startBitdeerWorker({ workerId: 'bitdeer-rack-1', model: 'm56', storeDir: './store/bitdeer-rack-1', mqttPort: 10883, allowedKeys: [kernel.getClientPublicKey()], seedDevices: [{ info: { serialNum: 'D40-M56-001', container: 'container-A' }, opts: { containerId: 'D40-M56-001' } }] }) await kernel.registerWorker(worker.runtime.getPublicKey()) ``` `allowedKeys` lists the RPC callers the Worker accepts. The Worker refuses to start without it (`ERR_WORKER_ALLOWED_KEYS_REQUIRED`), so pass the Kernel's client key. Make sure the container is configured to publish into this Worker's broker port before registering. Commands act on physical cooling and power hardware, prioritize thermal safety. `seedDevices` only seeds a fresh, empty `storeDir`, once persisted, the device set survives restarts on its own. To add a container to an already-running fleet, send the `registerThing` command to the live Worker instead: ```js const { createMdkClient } = require('@tetherto/mdk-client') const client = createMdkClient({ kernelKey: kernel.getPublicKey() }) await client.connect() await client.sendWorkerCommand('bitdeer-rack-1', null, 'registerThing', { id: 'D40-M56-002', info: { serialNum: 'D40-M56-002', container: 'container-A' }, opts: { containerId: 'D40-M56-002' } }) ``` `registerThing` persists the container config immediately, but the running Worker does not pick it up until it is stopped and restarted (`await worker.stop()`, then call `startBitdeerWorker` again with the same `storeDir` and no `seedDevices`), there is no hot-add. For the full `seedDevices` and `registerThing` option reference, the telemetry and command tables, and the shared install pattern, see [the Bitdeer README](https://github.com/tetherto/mdk/blob/main/backend/workers/containers/bitdeer/README.md) and [install pattern](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/install-pattern.md).
## Troubleshooting A working development run of `bitdeer-dev.js` prints the Kernel HRPC key and the registered device ID, then stays running until Ctrl+C. Start the mock only after that: it is an MQTT client and needs the Worker's broker up on `10883`. If the script does not print those values, or if broker port `10883` is already in use, the network and port checks in [miner troubleshooting](/guides/miners/troubleshooting) apply here too, the underlying HRPC and DHT requirements are the same across every Worker. ## Next steps - Decide your [deployment topology](/guides/deployment) to run the Worker service - [Review telemetry units, command shapes, and error codes](https://github.com/tetherto/mdk/blob/main/backend/workers/containers/bitdeer/README.md) # Run an MDK site (/guides/deployment) ## Overview Use these guides to choose a site deployment shape. If Kernel, Gateway, Worker, manager, or thing are unfamiliar, read [terminology](/reference/glossary) first. If you are choosing between topologies, read [deployment topologies](https://github.com/tetherto/mdk/blob/main/backend/core/docs/README.md#connection-and-deployment-model). ## Choose a guide - Run a multi-Worker site as separate, PM2-supervised processes, from one machine up to a cross-host deployment, using [supervised services](/guides/deployment/run-all-workers-site) - Configure MDK on managed infrastructure with the [hosted-site reference configuration](/guides/deployment/hosted-site): networks, allowlists, a monitoring-only and a delegated-writes profile, and MCP off or on loopback ## Next steps - Understand the trade-offs before you choose your [deployment topology](https://github.com/tetherto/mdk/blob/main/backend/core/docs/README.md#connection-and-deployment-model) - Measure real CPU, RAM, disk, and latency for your own hardware and device count with [the benchmark harness](/guides/deployment/benchmark-your-site) - Browse the [functions](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md) that wire together the [Kernel](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md), [device Workers](https://github.com/tetherto/mdk/blob/main/backend/workers/README.md), and the [Gateway](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) HTTP - Follow the [site security blueprint](/guides/security): identity, allowlists, and network options for a real site # Benchmark your site (/guides/deployment/benchmark-your-site) Turn a sizing guess into a number you can trust: real Kernel, Gateway, and Worker processes under real load, for your own hardware. Use the benchmark harness when you want a filled [capacity and metrics template](/guides/deployment/capacity-metrics-template) from measured runs on your own hardware, instead of by hand. See [when to use this](/guides/deployment/benchmark-your-site#when-to-use-this) for the specific cases. ## Run the benchmark - Follow the benchmark harness [config](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/config/benchmark.config.json.example) - Use the [quick start](/guides/deployment/benchmark-your-site#quickstart) Learn more from the [benchmark readme](/guides/deployment/benchmark-your-site#overview). The benchmark harness fills in the [capacity and metrics template](/guides/deployment/capacity-metrics-template) from measured runs. Every run uses the same process topology a real MDK deployment runs under PM2 — mocks, Kernel, Gateway, and each Worker as their own OS process (never blended into one Node process). ## Overview The benchmark harness boots real Kernel, Gateway, Worker, and mock-device processes (one mock TCP/HTTP listener per **Worker** — every device that Worker owns shares it, real network round trips, not in-memory fakes; any [Worker family](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/README.md) can be mixed into a single run). The harness: - Drives read/action/Gateway-request loads - Samples CPU/RSS/open-FDs per process - Runs real failure drills (Worker restart, Kernel restart, a fleet-wide unreachable-device outage) - Writes a filled profile (JSON + Markdown) per run plus a comparison matrix across a sweep. The generated Markdown mirrors the template's own section headings and table shapes exactly — real measurements where this harness has them, `_` (the template's own placeholder) everywhere it doesn't; see [What's measured vs. left blank](#whats-measured-vs-left-blank) for which is which. A single JSON config is the source of truth for the fleet to boot; see [Layout](#layout) for what each file does and [The config file](#the-config-file) for what it controls. ## When to use this - You want a real sizing answer for your own hardware and device count, not published numbers from a different tier - You need to confirm a configuration change still meets the pass/fail thresholds in [`capacity-metrics-template.md`](/guides/deployment/capacity-metrics-template) before it reaches production - You want failure-drill numbers (Worker restart, Kernel restart, a fleet-wide device outage) measured on your own hardware, not assumed from another run ## Quickstart ```bash # fast correctness check (5 devices, ~seconds) — wired into `npm test` npm test # the one benchmark run: boots every family in config.workers # simultaneously and sweeps the Cartesian product of every family's own # device-count range, lowest total device count first, stopping at the # first combination that goes red (the fleet's breaking point — see # capacity-metrics-template.md "Ceiling profiles") npm run benchmark ``` Every combination step writes `results/.json` and `.md`, named by that step's total device count and Worker count (e.g. `cap-150devices-2workers` for 150 Avalons across 2 Workers). The filename encodes only those two numbers, not the per-family split, so two combinations that reach the same total device count and Worker count collide: the later one's report overwrites the earlier one's. The run as a whole additionally writes one combined `results/sweep-benchmark-matrix.md` (generated under gitignored `results/` — not committed) across every combination tried. Commit the specific reports you want to keep alongside a sizing decision, not the whole directory. ## The config file [`config/benchmark.config.json`](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/config/benchmark.config.json.example) (copy from the linked `.example`) is the only file you edit to change what gets measured: | Section | Feeds | | ---------- | --------------------------------------------------------------------------------------------------------------- | | `hardware` | The template's "Reference hardware" block. Fill this in manually per host: the harness can't detect vendor/tier | | `workers` | One entry per device family, all booted simultaneously and swept together | Each `workers` entry is `{ type, model, simulateMocks, ceiling: { startDeviceCount, stepDeviceCount, maxDeviceCount } }`, for example, one entry from [`config/benchmark.config.json.example`](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/config/benchmark.config.json.example): ```json { "type": "mdk-worker-avalon", "model": "a1346", "simulateMocks": true, "ceiling": { "startDeviceCount": 10, "stepDeviceCount": 10, "maxDeviceCount": 30 } } ``` Note that `type` must be one of `Object.keys(WORKER_REGISTRY)` in [`lib/constants.js`](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/lib/constants.js) (one entry per package under [`backend/workers/miners/`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/README.md)), and `model` one of that `type`'s supported models. `ceiling.startDeviceCount` and `stepDeviceCount` must each be `>= 1`. `maxDeviceCount` caps how far that entry's dimension of the sweep goes, and both other fields must additionally fall within `[1, maxDeviceCount]`, unless there's only one entry in `workers`, in which case `maxDeviceCount` may be `0` (uncapped: keep raising the device count until a step actually goes red). With more than one entry, every `maxDeviceCount` must be a real number, since the sweep is the Cartesian product of every entry's range and an unbounded dimension can't be combined into a finite product. All of this is validated eagerly when the config loads. ### Fixed operating defaults Everything that isn't about sizing a run: - Host/discovery/data root - Kernel cadence - Worker operating intervals/timeouts/concurrency - `allowDuplicateIPs` - Mock port/auth password - Alert-induction thresholds - Failure-drill toggles - Run-reproducibility shape (`n`, soak duration, resource-sample interval, read/action load) - Pass/fail thresholds (headroom, action submit p99, steady CPU, rejects+timeouts, RSS slope) all this lives in [`lib/constants.js`](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/lib/constants.js). Workers always boot on their own package defaults (plus `ALERT_INDUCTION`'s threshold override, the one intentional exception: see [`lib/site.js`](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/lib/site.js)). `allowDuplicateIPs` is always on (every mock lives on `127.0.0.1`, one server per Worker; its devices share it, differentiated only by port). Mock ports are picked randomly per run (one per Worker, not per device), each Worker's password is read from its Worker type's own mock default, and `RUN_REPRODUCIBILITY`/`THRESHOLDS` are recorded verbatim into every report. ### Run a real leak-detection soak `RUN_REPRODUCIBILITY.soakMs` defaults to a short 60s so sweeps and CI stay fast. The template requires **≥ 24h** of soak before an RSS slope means anything as a real growth or leak signal rather than noise. To get that signal, bump `soakMs` to 24h or more in [`lib/constants.js`](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/lib/constants.js) and run a single profile, not a full sweep: a sweep repeats the soak once per combination it tries, so a 24h soak across a whole sweep would take days. Any run shorter than 24h still produces an RSS slope, but reports label it **indicative** rather than a confirmed signal. ## What's measured vs. left blank Measured automatically: - **Read path**: single-device telemetry both through the Gateway (`GET /api/fleet/device/{id}/telemetry`, a route of the harness's own app) and bypassing it (Client → Kernel → Worker directly); the aggregate fleet-wide read (Gateway → app runtime → Kernel → every Worker, through the harness's own generic [`plugin/fleet-summary`](https://github.com/tetherto/mdk/tree/main/backend/tests/benchmark/plugin/fleet-summary) app); device list/registry read. Every Gateway route takes one extra HRPC hop, Gateway → app runtime, that the direct Client path does not. - **Write/action path**: submit through the Gateway (`POST /api/fleet/device/{id}/action`, another route of that app) and the direct Client → Kernel path — both real HTTP/RPC round trips, not simulated. Submit and execute collapse into one measured step; see "Action-approval workflow steps" under Left blank for why. - **Cycle headroom** (worst Worker) and **sustained read/action throughput** (reads/s, actions/s, rejected, timed-out, peak queue depth). - **Per-process CPU/RSS** (sampled every tick via `ps -o rss,pcpu -p `) and **open file descriptors** (sampled at profile start and end via `lsof -p ` — more expensive than `ps`, so not sampled every tick), each with a real slope across the run (`ResourceSampler` in [`lib/metrics.js`](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/lib/metrics.js)) — **indicative** below a 24h soak, same as everywhere else growth/leak claims show up in this harness. The app runtime runs inside the `gateway` role's process, so the Gateway's samples include the app. - An **approximate** device-only baseline (raw TCP connect to the mock's port — a floor, not the full vendor-protocol round trip). - **Alerts path**: every device's temperature-warning threshold is forced below any real reading (see `ALERT_INDUCTION` in [`lib/constants.js`](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/lib/constants.js)), so the family's own alert genuinely trips on the first snap the Worker collects at its own (never-overridden) cadence. The harness measures how long after it starts watching that alert first becomes visible both via the Kernel directly (`pollAlerts` in [`lib/load.js`](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/lib/load.js)) and via the Gateway (`pollAlertsViaGateway`, hitting `GET /api/fleet/device/{id}/alerts` — a third route of the harness's app, since the Gateway has no other way to surface `last.alerts`) — `n = 0` on either just means the Worker's own snap interval (default 60s) didn't complete a cycle within this run's soak, not that induction failed. - **Storage breakdown**: real on-disk size per Worker store, plus a real growth/day computed from size at profile start vs. now, divided by the run's own elapsed time (same short-soak "indicative" caveat). - **Failure behaviour** (`runFailureDrills` in [`processes/run-process.js`](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/processes/run-process.js), toggled by `RUN_REPRODUCIBILITY.runFailureDrills`, on by default): real kill+respawn drills for Worker restart and Kernel restart (both processes' identity persists across a restart against the same on-disk root, confirmed empirically, so the existing client reconnects on its own), plus a device-outage drill. The outage drill can only make the **whole fleet** unreachable, not one device — every device behind a Worker shares one mock server — so it reports both the measured (fast-refusal, since a closed port is refused immediately rather than timing out) and an analytical hung-device worst case (`timeout × ⌈device count / concurrency⌉`) from already-known operating parameters. Runs once, after the steady-state checklist finishes, never during (so it can't contaminate the capacity numbers above). Left blank, with a note in the generated report: - **Action-approval workflow steps**: the full push → vote → execute workflow as distinct submit/approve/exec/e2e rows, vote/approve as its own step, and batch actions across N devices in one call. All three shipped Workers allowlist their write actions at a single required vote, and this harness only ever submits via `sendCommand` and its Gateway-mirrored route, never the Kernel's separate `pushAction`/`voteAction`/`queryActions` pipeline - **Alert generation latency in isolation**: synchronous inside the Worker process, the same reason heap/external memory is blank, needs in-process instrumentation this harness doesn't have - **Alert fan-out to N subscribers and historical alert queries**: need Gateway capabilities, a push/subscription mechanism and a history store, beyond a single request/response endpoint - **Kernel-internal scheduled telemetry pull and health ping**: no client-observable start/end signal distinct from the reads already measured ## Layout ```text config/benchmark.config.json the one input file lib/constants.js Worker-type registry + fixed defaults (never edited to size a run) + ALERT_INDUCTION (per-family threshold used to trip a real alert) + RUN_REPRODUCIBILITY.runFailureDrills/*TimeoutMs/deviceOutageMs lib/site.js boot primitives (bootKernel/bootWorker/bootGateway/startMocks) — one mock server per Worker, bootWorker wires ALERT_INDUCTION in, and bootGateway starts the fleet-summary app runtime before the Gateway lib/metrics.js per-process CPU/RSS sampler (ps -o rss,pcpu -p , every tick) + open-FD sampler (lsof -p , start/stop only) + dirSizeBytes lib/latency.js per-operation latency recorder (p50/p95/p99/max/n/errors) lib/load.js read/action load generators, cycle headroom, device baseline, pollAlerts/pollAlertsViaGateway (alert-visibility latency pollers) lib/report.js pass/fail evaluation + Markdown/JSON report + comparison matrix lib/sweep-runner.js spawns one child process per profile, folds results into entries plugin/fleet-summary/ app (mdk-app.yaml + mdk-plugin.json) every profile run mounts at `/`, run in an app runtime inside the gateway role's process: fleet-wide aggregate (listWorkers→pullTelemetry) plus per-device telemetry/action/alerts routes (controllers/device-telemetry.js, device-action.js, device-alerts.js) processes/run-process.js --role mocks|kernel|worker|gateway|profile (defaults to profile); profile spawns the other four roles as child processes, coordinates the checklist, then (unless disabled) runs runFailureDrills scenarios/benchmark.js sweeps the Cartesian product of every config.workers entry's device range, writes one comparison matrix tests/benchmark.smoke.test.js fast correctness check (not a capacity claim; skips failure drills for speed) results/ generated reports (gitignored) ``` ## Add a device family [`lib/constants.js`](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/lib/constants.js)'s `WORKER_REGISTRY` covers every package under [`backend/workers/miners/`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/README.md) (Avalon) — any of them can be used in `config.workers[].type`. To benchmark a family outside that directory (container, power meter, ...), add an entry to the registry's `WORKER_PACKAGES` map pointing at the target package's `startWorker` export; everything else (config, load generators, metrics, reporting) is device-family agnostic because it only talks to the family through `createMdkClient`. ## Next steps - Fill in the [capacity and metrics template](/guides/deployment/capacity-metrics-template) by hand for a topology this harness doesn't cover yet - Run [a multi-Worker site](/guides/deployment/run-all-workers-site) to reproduce a profile's topology before benchmarking it # Capacity and metrics template (/guides/deployment/capacity-metrics-template) ## Overview This template turns measured runs into **sizing answers**: given your hardware and `N` miners, how many Workers do you need, and what do they cost in CPU, RAM, and disk? Fill each profile with what is running, record host resources and end-to-end latencies, then derive per-device cost, cycle headroom, and pass/fail status. Use the same percentile set, soak rules, and load definition across profiles so rows stay comparable. Absolute numbers without reference hardware, device baseline, and a zero-device floor are not enough to size a site. Prefer formulas (`RAM ≈ base + n × per-device`) and headroom ratios over one-off tables. The [benchmark harness](/guides/deployment/benchmark-your-site) fills this template in from measured runs automatically. Fill it in by hand only for a topology it doesn't cover yet. ## What a filled profile should answer | Question | Where it comes from | | --- | --- | | How many devices per Worker? | Cycle time / configured interval (headroom); ceiling sweeps | | How many Workers for `N` devices? | Devices-per-Worker recommendation × `N`, validated by Worker-split sweeps | | CPU / RAM / disk for that layout | Zero-device baseline + per-device marginal cost | | Is this configuration supported? | Pass/fail thresholds (green / amber / red) | | What breaks first? | Ceiling profiles and failure-behaviour section | ## Reference hardware Record one hardware block per profile. ### Hardware block (per profile) | Field | Value | | --- | --- | | Reference tier | `edge` / `site-server` / `custom` | | CPU model | `_` | | CPU cores (physical / logical) | `_` / `_` | | RAM | `_` GiB | | Disk type | NVMe / SATA SSD / HDD / SD-class / other | | Disk size | `_` GiB | | OS | `_` | | Node.js version | `_` | | MDK version / commit | `_` | | Network path to devices | local LAN / VLAN / WAN / simulated-in-process | | Notes | `_` | ### Reference tiers | Tier | Intent | Typical shape (fill with your lab hosts) | | --- | --- | --- | | `edge` | Small box colocated with a rack or container | `_` cores, `_` GiB RAM, SD-class or SATA | | `site-server` | Proper site host for hundreds–thousands of devices | `_` cores, `_` GiB RAM, NVMe | Map a customer host to the nearest tier before quoting sizing numbers. If hardware differs materially (especially disk class or core count), treat results as indicative only. ## Capacity profile Define what is running before you measure anything. ### Workers List Worker types and device counts. | Worker type | Device count | Max Device count | real / simulated | | --- | --- | --- | - | | `_` | `_` | `_` | - | | `_` | `_` | `_` | - | ### Run reproducibility Define enough that someone outside the team can repeat the run. | Field | Value | | --- | --- | | Profile id | e.g. `cap-100dev-w2` | | `n` (samples per latency row) | `_` (minimum recommended: `_`) | | Soak duration | `_` (minimum **24 h** for growth / leak claims) | | Load generator | name / script / commit | | Read load definition | e.g. `R` concurrent telemetry reads, mix `_` | | Action load definition | e.g. `A` actions/s, types `_`, batch size `_` | | Alert induction method | real condition / injected / wait | | Config artifact path / hash | `_` | | Start time (UTC) | `_` | | End time (UTC) | `_` | ## Resource metrics template Record process-level and site-level resources. CPU and RSS are sampled cross-process (e.g. `ps -o rss,pcpu`), which any consumer can do without being inside the Node process — heap and external/buffer memory need in-process instrumentation instead, so they're out of scope here even though Hypercore traffic shows up outside the V8 heap. ### Per process Repeat one table per process (Kernel, each Worker, Gateway, MCP). | Metric | Unit | Warm (after READY) | Steady (end of soak) | Peak | Notes | | --- | --- | --- | --- | --- | --- | | CPU average | % of 1 core or host | `_` | `_` | `_` | | | CPU peak | % | `_` | `_` | `_` | | | RSS | MiB | `_` | `_` | `_` | | ### Site aggregate | Metric | Unit | Value | | --- | --- | --- | | Sum of process RSS | MiB / GiB | `_` | | Host CPU (all MDK processes) | % | `_` | ### Storage breakdown Per-Worker-store disk size and growth (Kernel/Gateway/alerts/logs storage isn't broken out per worker, so it's out of scope here; the [retention table](/concepts/the-storage-model#retention) says what bounds each store). Growth/day is a real delta — size at profile start vs. now — divided by the run's own elapsed time; label **indicative** unless the soak is at least 24 h. Projections are linear extrapolations of that rate, not a model of Hyperbee/Corestore's actual (non-linear) growth curve — do not extrapolate a short soak to 12 months and treat it as a commitment. | Store | Size now (MiB) | Growth / day (MiB) | Projected 6 mo (GiB) | Projected 12 mo (GiB) | | --- | --- | --- | --- | --- | | `_` | `_` | `_` | `_` | `_` | | **Total** | `_` | `_` | `_` | `_` | ### Zero-device baseline and per-device marginal cost Sums do not extrapolate. Measure a **zero-device** (or idle Worker with no owned devices) baseline, then subtract to get marginal cost. | Metric | Zero-device baseline | At `D` devices | Per-device marginal | Unit | | --- | --- | --- | --- | --- | | CPU (site, all MDK processes) | `_` | `_` | `_` | % of 1 core | | RSS (site, all MDK processes) | `_` | `_` | `_` | MiB | **Sizing formulas** (fill coefficients from the table): ```text RSS ≈ RSS_base + D × RSS_per_device CPU ≈ CPU_base + D × CPU_per_device ``` State whether coefficients are per Worker process or site-wide, and which reference tier they belong to. ## Device baseline (MDK overhead) Firmware round-trip dominates on real hardware. Record device-only response time so you can subtract it from MDK path latencies. | Measurement | Boundary | p50 ms | p95 ms | p99 ms | max ms | n | Real / simulated | | --- | --- | --- | --- | --- | --- | --- | --- | | Device-only telemetry / status read | Direct to device (no MDK) or mock equivalent | `_` | `_` | `_` | `_` | `_` | | | Device-only action ack | Direct to device | `_` | `_` | `_` | `_` | `_` | | | Derived | Formula | Value | | --- | --- | --- | | MDK overhead (read p99) | MDK path p99 − device-only p99 | `_` ms | | MDK overhead (action exec p99) | Exec path p99 − device-only p99 | `_` ms | The number that makes MDK credible for sizing is the **overhead MDK adds** on top of what the device itself takes. ## Cycle headroom (devices per Worker signal) For each profile, record how long a full collection cycle takes against the interval it is configured for. The ratio is headroom; the point where it crosses **1.0** is the practical answer to "how many devices per Worker." | Metric | Value | Unit | | --- | --- | --- | | Configured telemetry interval | `_` | ms | | Full collection cycle time (all owned devices) | `_` | ms (p50 / p95 / p99: `_` / `_` / `_`) | | Cycle headroom ratio | `cycle_time` / interval | `_` | | Devices owned by this Worker | `_` | count | | Unreachable devices during cycle | `_` | count | | Timeout budget consumed by unreachable devices | `_` | ms | | Headroom | Meaning | | --- | --- | | `< 0.7` | Comfortable | | `0.7–1.0` | Amber — little spare capacity | | `≥ 1.0` | Overrun — reduce devices per Worker or raise concurrency / interval | ## Throughput under load Record sustained rates and backpressure. | Metric | Value | Unit | | --- | --- | --- | | Sustained telemetry reads / s | `_` | 1/s | | Sustained actions / s | `_` | 1/s | | Queue depth (peak / steady) | `_` / `_` | count | | Rejected requests | `_` | count | | Timed-out requests | `_` | count | ## Latency metrics template All latencies in **milliseconds** use the same percentile set across profiles so rows are comparable. Record `n` on every row (see run reproducibility). ### Read path (telemetry and state) Time from consumer request until usable payload returns. | Operation | Boundary | p50 ms | p95 ms | p99 ms | max ms | n | errors | | --- | --- | --- | --- | --- | --- | --- | --- | | Telemetry read (single device) | Gateway → Kernel → Worker → Gateway | `_` | `_` | `_` | `_` | `_` | `_` | | Telemetry read (single device) | Kernel → Worker only | `_` | `_` | `_` | `_` | `_` | `_` | | Telemetry / overview read (aggregate) | Gateway (+ plugin) → Kernel → Workers | `_` | `_` | `_` | `_` | `_` | `_` | | Device list / registry read | Gateway → Kernel | `_` | `_` | `_` | `_` | `_` | `_` | Scheduled Kernel telemetry pull and health ping are Kernel-internal timers with no client-observable start/end signal distinct from the reads above — not independently measurable without in-process Kernel instrumentation, so they're not tracked as separate rows. ### Write / action path Split **submit** and **execution** so bottlenecks are visible. A separate approval round only exists if the Worker's write-action whitelist requires more than one vote — every shipped worker whitelists at a single required vote, so submit and execute collapse into one step. | Operation | Boundary | p50 ms | p95 ms | p99 ms | max ms | n | errors | | --- | --- | --- | --- | --- | --- | --- | --- | | Send / submit action | Client → Gateway → Kernel accept | `_` | `_` | `_` | `_` | `_` | `_` | | Action execution (Kernel dispatch → Worker → device ack) | Kernel ActionCaller → Worker → device | `_` | `_` | `_` | `_` | `_` | `_` | | End-to-end write action (submit → executed / terminal state) | Client → … → device → client-visible result | `_` | `_` | `_` | `_` | `_` | `_` | Record separately by action type (for example reboot vs setPowerMode vs `updateThing`). | Action type | `reqVotes` | e2e p50 ms | e2e p99 ms | exec-only p50 ms | exec-only p99 ms | n | | --- | --- | --- | --- | --- | --- | --- | | `_` | `_` | `_` | `_` | `_` | `_` | `_` | ### Alerts path Track delivery — when Kernel or Gateway consumers can read the alert — separately by boundary. Alert generation (condition → persisted alert record) happens synchronously inside the Worker process and isn't independently observable without in-process instrumentation, so it isn't tracked as its own row. | Operation | Boundary | p50 ms | p95 ms | p99 ms | max ms | n | errors | | --- | --- | --- | --- | --- | --- | --- | --- | | Alert visible via Kernel (pull / list / query) | Consumer → Kernel → alert source | `_` | `_` | `_` | `_` | `_` | `_` | | Alert visible via Gateway (HTTP / WebSocket / plugin) | Consumer → Gateway → Kernel / store | `_` | `_` | `_` | `_` | `_` | `_` | Fan-out to N subscribers and historical (range-read) alert queries need Gateway capabilities (a subscription/push mechanism, a history store) beyond a single request/response endpoint — out of scope until the Gateway carries one. ## Pass / fail thresholds Set site-wide defaults, then mark each profile green / amber / red. | Criterion | Green | Amber | Red | Profile result | | --- | --- | --- | --- | --- | | Telemetry freshness (cycle ≤ interval) | headroom `< 0.7` | `0.7–1.0` | `≥ 1.0` | `_` | | Action e2e p99 | `≤ _` ms | `≤ _` ms | above amber | `_` | | Steady-state CPU (host or busiest process) | `≤ _` % | `≤ _` % | above amber | `_` | | Rejected + timed-out under sustained load | `0` | `< _` | otherwise | `_` | | RSS slope over 24 h soak | flat / `_` MiB/h | `_` | clear leak | `_` | **Supported up to** means the densest green profile on that reference tier (devices per Worker and total `D`). Publish that bound explicitly after ceiling sweeps. ## Failure behaviour Measure process failure recovery on at least one profile per reference tier. "Unreachable device" can only be induced fleet-wide if every device behind a Worker shares one mock/gateway endpoint rather than an isolated one per device — note the actual scope achieved, not just "one device" by assumption. | Scenario | Metric | Value | Notes | | --- | --- | --- | --- | | Worker restart | Time to healthy / owning devices again | `_` ms | | | Kernel restart | Time to READY | `_` ms | | | Unreachable device | Effect on cycle time (measured) | `_` | live drill result; note whether the failure mode was a fast refusal or an actual hang | | Unreachable device | Effect on cycle time (analytical, hung-device worst case) | `_` | timeout × ⌈unreachable count / concurrency⌉ | | Unreachable device | Timeout budget per device | `_` ms | from operating parameters | | 24 h soak | RSS slope (MiB / h) per process | `_` | leak detection | | 24 h soak | FD / socket slope (per hour) per process | `_` | | ## Profile comparison matrix One row per capacity profile. Copy columns as needed. Leave cells blank until measured. Prefer profile ids like `cap-10dev` / `cap-100dev-w2` (not `cap-10m`, which reads as ten minutes). | Profile id | Tier | Real/sim | Kernels | Gateways | Plugins | W | D | Alert rules | CPU sum % | RSS sum | Heap sum | Disk now | Disk/day | Headroom | Reads/s | Actions/s | Rejects+timeouts | Read p99 | Action e2e p99 | Alert gen p99 | Status | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `cap-10dev` | `_` | `_` | 1 | 1 | 1 | 3 | 12 | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | | `cap-50dev` | `_` | `_` | 1 | 1 | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | | `cap-100dev` | `_` | `_` | 1 | 1 | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | | `cap-500dev` | `_` | `_` | 1 | 1 | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | | `cap-1000dev` | `_` | `_` | 1 | 1 | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | | `cap-5000dev` | `_` | `_` | 1 | 1 | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | | `cap-custom` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | `_` | ## Measurement checklist Use the same checklist for every profile so results stay comparable. 1. Record reference hardware (tier, CPU, RAM, disk class, OS, Node, MDK, network path) 2. Record the capacity profile (control plane, Workers, devices, real vs simulated, operating parameters including alert rule count) 3. Record run reproducibility (`n`, soak ≥ 24 h for growth/leak claims, load generator, config hash) 4. Bring the site to READY; capture zero-device baseline if this run includes marginal-cost derivation 5. Sample CPU and RSS on a fixed interval throughout the soak; sample open FDs/sockets and on-disk store size at start and end (heap/external memory need in-process instrumentation and stay out of scope) 6. Measure device-only baseline response times; derive MDK overhead 7. Measure full telemetry cycle time vs configured interval (headroom) 8. Drive a defined read load; record read-path latencies and sustained reads/s 9. Drive a defined action load (single and batch); record submit, exec, e2e, actions/s, queue depth, rejects/timeouts 10. Induce or wait for alerts; record Kernel and Gateway visibility latencies (alert generation itself is Worker-internal and not independently observable) 11. Run failure drills (Worker restart, Kernel READY, unreachable-device impact — measured live plus the analytical hung-device worst case) for the profile set 12. Compute storage growth per Worker store from the soak (size at start vs. now, divided by elapsed time); label 6/12-month projections **indicative** 13. Apply pass/fail thresholds; copy summary into the comparison matrix and relevant sweep tables 14. Note failures, timeouts, config drift, and which single variable changed vs the previous profile # Hosted-site reference configuration (/guides/deployment/hosted-site) MDK ships no user identity at any tier. Every control on this page admits or scopes a backend process by its key, or a network by its reach. The person or agent behind a request is identified only by the `authVerifier` you give the Gateway. ## Overview This page is a reference configuration for MDK on a hosted or managed mining site: fixed hosts, a controlled network, a change process, and an operator who needs to hand a network team one page. It gives values, not rationale; the [site security blueprint](/guides/security) owns the reasoning behind each control, and the locked-down site shape explains why discovery and transport are separate decisions. Pick one of the two profiles, apply the settings table, and read the can and cannot lists before promising anything to the site owner. ## Networks and ports Three networks, as the blueprint suggests. Every HRPC hop is UDP over HyperDHT, addressed by public key, so there is no fixed listener port to open between MDK processes; what each host needs is UDP reachability to its peers and to the bootstrap nodes. | Network | Component | Protocol and port | Direction | | -------- | ------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------- | | Operator | Gateway HTTP | TCP `spec.gateway.port`, behind the TLS proxy you run | Inbound from operators and the UI origin only | | Operator | Operator shell (UI) | Static files served by that same proxy | Inbound from operators | | Backend | Kernel, Workers, app runtimes | UDP, ephemeral local ports; HyperDHT bootstrap `49737` (public set) or your private list | Peer to peer inside the backend network; outbound to the bootstrap nodes | | Backend | MCP server | TCP `127.0.0.1:`, loopback only | Local processes on the MCP host | | Backend | Agent model provider | TCP to `qvac` or your OpenAI-compatible URL | Outbound from the agent host only | | Devices | [Avalon](/guides/miners/run-avalon-worker) (CGMiner) | TCP `4028`, no authentication | From the owning Worker only | | Devices | [Whatsminer](/guides/miners/run-whatsminer-worker) | Encrypted TCP `4028` (API v2) or `4433` (v3), session token from the API password | From the owning Worker only | | Devices | [Antminer](/guides/miners/run-antminer-worker) HTTP | TCP `80`, digest authentication | From the owning Worker only | | Devices | Containers, meters, sensors | Vendor API port, vendor authentication | From the owning Worker only | | Devices | Pool APIs | HTTPS outbound | From the pool Worker only | Docker ports published by `startServices()` bind `127.0.0.1` unless `host` is set, so a containerised stack exposes nothing to the backend network by accident. ## What MDK can and cannot do here MDK can: - Admit backend callers to Kernel by key and scope each one to `read`, `write` or `admin` ([Kernel roles](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#caller-admission-and-roles)) - Admit callers to each Worker by key (`allowedKeys`, fail-closed) ([Worker allowlist](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/README.md#caller-allowlist)) - Admit Workers to Kernel by key (`discovery.workerKeys`, fail-closed) - Restrict the device addresses a Worker may register to a CIDR allowlist, `conf.thing.allowedAddressCidrs` ([device addresses](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md#device-data-and-caller-input)) - Serve the Gateway only to known `Host` names, rate limit by client IP, cap body size and send Helmet headers - Expose only read-only tools over MCP by default, require a bearer token on `/mcp`, and bind MCP to loopback with no option to do otherwise - Hold every write that changes hash, power or network membership for approval ([write actions](/guides/gateway/write-actions)) - Run with no outside network at all, against a private bootstrap list every component shares (programmatic hosts pass the list; `mdk run` uses the public DHT) MDK cannot: - Identify a person or an agent. Only the Gateway `authVerifier` you write does that, and nothing below the Gateway sees it. - Allowlist Gateway clients by IP. The Gateway has a `Host` allowlist and a per-IP rate limit; source-IP filtering is your proxy's or firewall's job. - Offer a global read-only switch. Monitoring-only is a composition of the controls in Profile A, not one flag. - Enforce an app's declared `permissions`. An app runtime key is a full Kernel principal; review an app before adding it to `spec.apps` ([app runtime](https://github.com/tetherto/mdk/blob/main/backend/core/app-runtime/README.md)). ## Profile A: monitoring-only The site reads telemetry and state. No exposed route, tool or Kernel caller can send a command to a device. - Kernel admits the Gateway, the MCP server and each app runtime key with `roles: ['read']`; no entry carries `write` or `admin` - Every Worker admits the Kernel client key (`kernel.getClientPublicKey()`) and the Gateway key, the two callers that dial Workers directly; the Worker allowlist carries no roles, so Kernel's `read` scope does not bind the Gateway's [direct calls](/guides/security#step-3-isolate-workers-and-devices) - `spec.apps` lists no app whose route table carries a write route; the operator shell therefore shows no write controls - MCP is absent, or runs with the default tool set and no `allowWrite` or `allowTools` - The approval flow is never reached: with no `write` caller, `action.push` is refused at the transport A `read` caller that sends `command.request` or `action.push` through Kernel gets `ERR_KERNEL_ACTION_FORBIDDEN`. The Gateway's direct Worker path carries no roles, so monitoring-only also rests on `spec.apps` exposing no write route and on the Gateway host staying yours. ## Profile B: delegated writes Named operators may change the fleet, and every fleet-changing write waits for approval. - Kernel admits the Gateway with `roles: ['write']`, the MCP server with `roles: ['read']` unless agents may act, and each app runtime key with the narrowest role its routes need - Workers admit the Kernel client key and the Gateway key, as in Profile A - The Gateway runs an `authVerifier` backed by your identity provider, marks every route that reads or changes the fleet `"auth": true`, and maps verified roles onto `authPerms` (`miner:w`, `container:w`) and `voter`; it never accepts either from the HTTP client ([auth in app controllers](/guides/gateway/apps#auth-and-permissions)) - Power mode, pool assignment, container PDU and bulk reboot go through `action.push` with the vote threshold the site owner signed off; single-device, operator-in-front actions may use a direct `command.request` behind a controller that checked the token - MCP write tools stay hidden unless `spec.mcp.allowWrite` or `spec.mcp.allowTools` names them, and the MCP server then carries an `identity` whose `perms` match the routes it may call, behind an authenticated front door ## MCP: off or loopback Off means not started: MCP is never part of the default `mdk run` target, so a stack that does not run `mdk run mcp` and does not list the agent app in `spec.apps` has no MCP surface and no agent. That is the Profile A default. On means loopback. The [MCP server](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) listens on `127.0.0.1` at the Gateway port plus 100 (or `spec.mcp.port`), and there is no option to bind elsewhere. Reaching it from another host is your reverse proxy's or tunnel's decision, and that front door must authenticate, because MDK does not. With it on: - Write tools are hidden unless `spec.mcp.allowWrite: true` or `spec.mcp.allowTools` lists them - `spec.mcp.token` (use a `${MDK_MCP_TOKEN}` placeholder resolved from the environment or `.env`) requires `Authorization: Bearer` on every `/mcp` call - `spec.mcp.identity` supplies the `perms` that `"auth": true` routes need; without it those routes are not exposed as tools - Requests whose `Host` is not a loopback name, or that carry an unlisted `Origin`, get `403` The operator agent, if you run it, keeps `approvalTimeoutMs` on and receives a real `userId` from the Gateway identity, so a model never executes a write that no one approved. ## Gateway and Kernel settings Each row names the setting, its configuration surface, and the value for each profile. Some controls are available through `mdk.yaml`; others require programmatic startup or component configuration files. `mdk run` does not expose Kernel caller roles, pinned Worker admission, or the Gateway bind address as stack settings. It admits callers from the trusted-keys directory with full Kernel access, so it does not establish the restricted roles in these profiles. A programmatic host that assigns caller roles must disable automatic directory admission with `trustedKeysDir: false` and explicitly manage caller admission. The [trust and admission reference](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md#trust-and-admission) describes automatic key exchange. Pinned Worker admission through `discovery.workerKeys` applies to topic discovery; local discovery and direct registration skip that check. | Setting | `mdk.yaml` | Programmatic | Profile A | Profile B | | -------------------------------- | ------------------------------------------- | --------------------------------------------------------- | --------------------------------- | ------------------------------------------ | | Kernel caller allowlist and roles | Not exposed; trusted-directory callers receive full access | `getKernel({ hrpc: { whitelist }, trustedKeysDir: false })`; explicitly manage later admissions | Every entry `roles: ['read']` | Gateway `['write']`, others as narrow as their routes allow | | Kernel `allowAll` | never | `hrpc.allowAll` | `false` | `false` | | Worker admission to Kernel | Not exposed; CLI uses local discovery | `getKernel({ discovery: { workerKeys } })`, `kernel.allowWorkerKey()` for topic discovery | Every topic-discovered Worker key, pinned | Same | | `allowAnyWorker` | never | `discovery.allowAnyWorker` | `false` | `false` | | Worker caller allowlist | set by `mdk run` from the trusted-keys dir | boot function `allowedKeys`, `MDK_WORKER_ALLOWED_KEYS` for `worker.js` | Kernel client key plus the Gateway key | Same | | Device address allowlist | Not exposed | `conf.thing.allowedAddressCidrs` on Worker boot functions | The device VLAN CIDR | Same | | DHT bootstrap list | Not exposed; `mdk run` uses the public DHT | `getKernel({ hrpc: { bootstrap } })`, `bootstrap` on Worker boot functions and `startGateway()`; no `worker.js` override | Private list if outbound UDP is blocked | Same | | Gateway bind address | Not exposed; configure the Gateway HTTP facility | `startGateway({ httpd })` overrides or the Gateway's `config/facs/httpd.config.json` | The proxy-facing interface | Same | | Gateway `allowedHosts` | `spec.gateway.config.allowedHosts` | `startGateway({ allowedHosts })` | The names operators type | Same | | Gateway `trustProxy` | `spec.gateway.config.trustProxy` | `startGateway({ trustProxy })` | `true` only behind your proxy | Same | | Gateway `rateLimit` | `spec.gateway.config.rateLimit` | `startGateway({ rateLimit })` | Default `300` per minute per IP | Lower on write routes | | Gateway `authVerifier` | `spec.gateway.config.authVerifier` names a built-in; a function needs code | `startGateway({ authVerifier })` | Your verifier; `'loopback'` reads the socket address, so never behind a proxy | Your verifier, required | | MCP | `spec.mcp` absent, or `port`, `allowWrite`, `allowTools`, `token`, `identity` | `createMcpServer(root, port, config, pluginDirs, apps, options)` | Absent, or defaults | `token` set, `allowWrite` only with an authenticated front door | | Signal handling | handled by `mdk run` | `handleSignals: true` on `getKernel`, `createMcpServer` and a standalone `startGateway` (ignored with an in-process `kernel`); `worker.js` installs no handlers | Set where offered, so a supervisor stop runs cleanup | Same | | Secrets | `${VAR}` placeholders resolved from the environment, then `.env` (mode `0600`) | Injected by your secret manager | Never in `mdk.yaml`, git or a contract | Same | When Gateway is started with `startGateway({ kernel })` using a Kernel returned by `getKernel()`, set `handleSignals: true` on `getKernel()` so Kernel's cleanup also stops Gateway. A custom host can instead own signal handling and shut down its components through the [shutdown API](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md#shutdownhandle--promisevoid). Sending a supervisor stop signal alone does not guarantee that application cleanup runs. ## Next steps - Follow the [site security blueprint](/guides/security): the rationale and trade-offs behind every row - Understand the locked-down site shape: why pinned keys remove the topic but not the DHT - Map verified users onto `authPerms` in [app controllers](/guides/gateway/apps#auth-and-permissions) - Stage fleet-changing writes with the [write-actions guide](/guides/gateway/write-actions) - Check the [environment requirements](/reference/environment) for the Node.js and npm baseline # Run a multi-process site (/guides/deployment/run-all-workers-site) This page directs you to the correct location for the prerequisites, run command, smoke test, and troubleshooting. ## Overview Use this example when you want to run a demo for multiple configured Workers across device families - a miner, a mining pool, and a powermeter - each supervised as its own separate process. Each talks to mock hardware that speaks the real wire protocol. The site app surfaces all device data through a single `/site` HTTP API. This example runs the [local topology](/guides/deployment) under PM2 supervision. Use this when: - You want to explore a multi-Worker site and its telemetry in one running system - You need supervisor-managed restarts and logs, and want to restart or scale one service without restarting the others - You are testing PM2 orchestration before deploying to hardware, or want a production-like layout for Gateway and Workers - You want real driver code running its full connect, collect, and command paths (only the endpoints are localhost mocks instead of hardware) - You want the site app as a starting point for your own `/site` API You have a choice of [deployment topologies](/guides/deployment) from single-process to distributed microservices. This example's [`config/site.deploy.json`](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/config/site.deploy.json.example) sets `discovery` to `"local"` by default (Kernel and Workers share one machine, discovery via shared directory). Setting it to `"dht"` moves discovery onto Hyperswarm so Workers can run on separate hosts, but the example's own README doesn't walk through that mode end-to-end. ## Run the example Follow the [Starter site example](https://github.com/tetherto/mdk/tree/main/examples/mvp-site): - Start with the [prerequisites](https://github.com/tetherto/mdk/tree/main/examples/mvp-site#prerequisites) - Use [PM2](https://github.com/tetherto/mdk/tree/main/examples/mvp-site#start-the-site) for local process supervision on one host - [Verify](https://github.com/tetherto/mdk/tree/main/examples/mvp-site#start-the-site) the fleet is up ## Next steps - Understand the trade-offs between [deployment topologies](/guides/deployment) - [Register a single miner](/guides/miners) before building a site config - Extend the Gateway HTTP API with [custom plugins](/guides/gateway/apps) - Browse the [functions](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md) that wire together the [Kernel](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md), [device Workers](https://github.com/tetherto/mdk/blob/main/backend/workers/README.md), and the [Gateway](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) HTTP - Build your own [Worker from scratch](/guides/workers/build-a-worker) # Gateway how-to guides (/guides/gateway) ## Overview The Gateway is a container that hosts plugins and delivers an HTTP interface for your frontend: each plugin builds its own [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) from its context. These guides cover how to run it and extend it with the plugin system. An AI agent reaches MDK over MCP, not the Gateway's HTTP surface directly. MCP is served by a standalone [`@tetherto/mdk-mcp`](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) process that derives tools from a mounted plugin's routes; the Gateway hosts no MCP itself. If Gateway, Kernel, or plugin are unfamiliar, [terminology](/reference/glossary) defines them. The [Gateway concept page](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) covers the full developer model (extension, data access, auth design). ## Choose a guide | Goal | Guide | | --- | --- | | Start the Gateway for the first time | [Run the Gateway](/guides/gateway/run) | | Declare the apps MDK ships, or build and publish your own | [Gateway apps](/guides/gateway/apps) | | Stop Kernel, Gateway, and Workers cleanly | [Tear down MDK services](/guides/gateway/teardown) | | Operator in the loop: submit and approve write actions | [Submit and approve write actions](/guides/gateway/write-actions) | | Secure the site you are assembling | [Site security blueprint](/guides/security) | ## Next steps - [Understand the Gateway as a development surface](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) - Read the [Gateway API reference](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) - Choose a [deployment shape](/guides/deployment) - [Give an operator a chat interface to the fleet](/guides/agent) by deploying the operator agent behind the Gateway - Follow the [site security blueprint](/guides/security): identity in controllers, Kernel allowlist, and UI session options # Gateway apps (/guides/gateway/apps) ## Overview The Gateway serves HTTP routes from apps. An app is a package with an `mdk-app.yaml` manifest, a route table (`mdk-plugin.json`, in `plugin/` or at the package root) and one controller file per route. Each app runs in its own process under [`@tetherto/mdk-app-runtime`](https://github.com/tetherto/mdk/blob/main/backend/core/app-runtime/README.md): the Gateway reads only the route table and forwards each request to that runtime over HRPC, so no app code runs inside the Gateway. A Gateway serves exactly the apps your stack declares in `spec.apps[]` and nothing else. An app builds its own [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) to call into the Kernel. No knowledge of the MDK Protocol envelope or internal message shapes is required. ## Prerequisites - The [`mdk` CLI is installed](/guides/cli/install), with a project `mdk.yaml` (`mdk onboard` writes one) ## Apps MDK ships The [apps MDK ships](/reference/supported-apps) are the source of truth for their package names and the routes each serves. Declare one by package name: ```yaml # mdk.yaml spec: gateway: port: 3847 apps: - name: demo package: "@tetherto/mdk-app-demo" ``` A stack serves only the apps it declares. Any other path answers `404`. If a [React adapter](https://github.com/tetherto/mdk/blob/main/ui/packages/react-adapter/README.md) hook reads a route, declare the app that serves it. Third-party apps are documented by their own manifests. `mdk add app ` installs one from npm or GitHub, checks it with the same rules as `mdk validate app`, and only then adds it under `spec.apps`. ### Create an app ```bash mdk create app fleet ``` This scaffolds `apps/fleet`, links it into the project's `package.json`, and adds it under `spec.apps` in `mdk.yaml` (`--no-stack-entry` prints the entry instead). The scaffold carries one read-only summary route: ```text apps/fleet/ ├── mdk-app.yaml # app manifest ├── package.json └── plugin/ ├── mdk-plugin.json # route table ├── lib/client.js # the app's MDK client └── controllers/summary.js # one module per route ``` `mdk-app.yaml` describes the app to the operator: | Field | Status | Description | | ------------- | -------- | ------------------------------------------------------------------------------- | | `id` | Required | Reverse-DNS id, e.g. `com.example.fleet` | | `version` | Required | Semver, equal to the `package.json` version | | `displayName` | Required | Human name | | `description` | Optional | One-line summary | | `permissions` | Optional | Rules `{ resources, verbs, resourceNames? }` the app asks the operator to approve | | `config` | Optional | Default config handed to the controllers | | `main` | Optional | The app's background logic, `[#export]` beside the route table. Called as `main(config, context)` and awaited before the runtime first serves, so a store or collector it sets up is ready for the controllers | The Kernel does not enforce `permissions` yet. They are declared for operator review only: the app runtime's Kernel key is admitted with full access, like every key in `.mdk/trusted-keys/`. `mdk validate app fleet` checks the manifest, its version against `package.json`, the permission rules and the route table, by the same rules the app runtime and the Gateway apply at boot (the version check is the validator's own). ### Write the routes #### 2.1 Edit the route table `mdk-plugin.json` declares the plugin identity (`name`, `version`) and a `routes` array. Each route needs an `id`, a `handler` path, and either an `http` block with a `method` and `path`, or those same `method`/`path` fields flattened to the route's top level; the bundled agent app uses the flat form. Rather than copy a synthetic example, start from a real manifest and trim it: - [`backend/apps/demo/mdk-plugin.json`](https://github.com/tetherto/mdk/blob/main/backend/apps/demo/mdk-plugin.json): two read-only `GET` routes with query parameters, each annotated with `safety` - [`examples/mvp-site/backend/gateway-plugins/site/mdk-plugin.json`](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/backend/gateway-plugins/site/mdk-plugin.json): four routes including `GET`s with query parameters, and `POST`s with a `requestBody` and path parameters - [`backend/apps/agent/mdk-plugin.json`](https://github.com/tetherto/mdk/blob/main/backend/apps/agent/mdk-plugin.json): `auth: true` routes in the flat form, one of them a stream route Path parameters use `{param}` syntax — the loader normalises them to Fastify's `:param` format. For named exports use `"handler": "./controllers/foo.js#namedExport"`. The plugin reference explains what each field means and what the loader requires. #### 2.2 Write a controller A controller builds its own [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) once, from the app's context config, in a `lib/client.js` every controller in the app requires. Every controller exports an `async function (req)`: ```js // controllers/live.js — read live telemetry const mdkClient = require('../lib/client') module.exports = async function live (req) { const deviceId = req.query.deviceId const telemetry = await mdkClient.pullTelemetry(deviceId, 'metrics') return { deviceId, ...telemetry } } ``` ```js // controllers/command.js — dispatch a command const mdkClient = require('../lib/client') module.exports = async function command (req) { const deviceId = req.params.deviceId const { mode } = req.body const result = await mdkClient.sendCommand(deviceId, 'setPowerMode', { mode }) return { deviceId, commandId: result.commandId, status: result.status } } ``` ### Declare and mount it `mdk create app` already wrote this entry; a hand-written one has the same shape: ```yaml spec: apps: - name: fleet # ^[a-z][a-z0-9-]*$, unique in the stack package: fleet # npm name or local path config: {} # optional: overrides the mdk-app.yaml config defaults mount: / # optional: serve the routes at the root instead of /apps/fleet ``` The Gateway serves an app's routes under `/apps/` unless `mount` is set, so the scaffold's `GET /api/fleet/summary` answers at `/apps/fleet/api/fleet/summary`, or at `/api/fleet/summary` with `mount: /`. Mount an app at the root only when a client calls its paths there, as the [agent app](https://github.com/tetherto/mdk/blob/main/backend/apps/agent/README.md) needs for its UI. ### Run it ```bash mdk run # Kernel, Workers, one runtime process per app, then the Gateway mdk run app fleet # or only this app, in the foreground, beside `mdk run kernel` and `mdk run gateway` ``` The runtime loads the route table and every handler before it serves, so a missing file or invalid manifest fails that app at startup. It accepts calls only from the Gateway's key (`.mdk/gateway.key`); until it is up, its routes answer `503`. ```bash curl http://localhost:3847/apps/fleet/api/fleet/summary ``` [Exposing an app's routes to the operator agent](/guides/agent/expose-data) turns them into MCP tools with no separate manifest. ### The app's UI module An app's routes are one half of it, and this is the other. `ui/index.js` default-exports `defineMdkApp` from [`@tetherto/mdk-react-adapter`](https://github.com/tetherto/mdk/blob/main/ui/packages/react-adapter/README.md), which types the contract a shell loads: ```tsx // ui-src/index.tsx, built to ui/index.js id: 'com.example.fleet', displayName: 'Fleet', routes: [{ path: '', component: Overview }], panels: [{ id: 'drift', slot: 'home', component: DriftCard }] }) ``` `mdk create app` scaffolds `ui-src/` with one page and one Home panel, plus a placeholder `ui/index.js` that `npm run build:ui` replaces. | Field | Contains | | ------------- | ------------------------------------------------------------------------------------------------ | | `id` | The app's reverse-DNS id. Must equal `id` in `mdk-app.yaml`: the shell checks it against the installed app's id, so a package cannot load under another app's identity | | `displayName` | The name the shell shows, and the fallback for a route that sets no `label` or a panel that sets no `title` | | `routes` | Pages the shell mounts under `/app//`, in the app's own sidebar group. `path` is relative, `''` being the landing page; `label` sets the sidebar label; `hidden` routes a deep link without listing it | | `panels` | Components the shell composes into pages it owns. `slot` picks where: every MDK shell provides `MDK_APP_PANEL_SLOT.HOME`, and a shell ignores slots it does not know | The dashboard serves its app list at `/app-ui.json`, written from the stack's `mdk.yaml` by `mdk run dashboard`. It imports each app's `ui/index.js`, checks it against the contract, and adds its stylesheets to the page, so installing an app needs no dashboard change: restart the dashboard and the app's pages and panels are there. An app whose UI fails to load is reported on its own instead of taking the dashboard down. A built UI module imports React and the MDK packages by bare specifier, and the dashboard resolves those to its own instances through an import map. A shell that does not map them fails to load every app UI. ### Publish and install an app An app is an npm package, so shipping one is `npm publish` or a Git tag. What makes it installable is what the tarball carries and what the consumer runs. #### Package it `package.json` `files` has to ship `mdk-app.yaml`, `plugin/` and `ui/`. Commit the built `ui/` rather than building on install: a GitHub install runs no build, so an unbuilt `ui/` reaches the consumer as a missing module. ```json { "files": ["mdk-app.yaml", "plugin/", "ui/", "README.md"] } ``` Run `mdk validate app ` before publishing or tagging. It checks the manifest, its version against `package.json`, the permission rules and the route table by the same rules the app runtime and the Gateway apply at boot. #### Publish it Either source works, and an app does not have to reach npm at all: ```bash git tag v0.2.0 && git push --tags # install from GitHub at that tag npm publish --access public # or publish under your own scope ``` #### Install it A consumer adds the app to their stack from either source, then starts it: ```bash mdk add app @your-org/mdk-app-fleet@^1 mdk add app github:your-org/mdk-app-fleet#v0.2.0 --name fleet mdk run ``` `mdk add app` installs with `npm install --ignore-scripts --save-exact`, so no install script of an unchecked package runs and the exact version is recorded. It then checks the package with the same rules as `mdk validate app` and only then writes the `spec.apps` entry, keeping the stack file's comments and formatting. A refused package leaves `mdk.yaml`, `package.json` and the lockfile as they were. The [CLI reference](https://github.com/tetherto/mdk/blob/main/packages/cli/README.md#adding-and-validating-apps-and-workers) covers `--name`, repeat installs and the worker equivalent. A GitHub source cannot be added with `--no-install`: its package name is only known once npm has fetched it. #### Declare what it needs An app states its own config defaults under `config` in `mdk-app.yaml`, and the operator overrides them per stack. [Supplying per-app config](#supplying-per-app-config) covers the layering. ### Start apps from code Outside the CLI, pass each app to `startGateway()` from `@tetherto/mdk-core` as `apps` (the complete list, nothing else is mounted) and run each one with `createAppRuntime()`, allowing only the Gateway's key: ```js const path = require('path') const { startGateway } = require('@tetherto/mdk-core') const { createAppRuntime } = require('@tetherto/mdk-app-runtime') const appDir = path.join(__dirname, 'apps/fleet') const runtime = createAppRuntime({ name: 'fleet', appDir, kernelKey: kernel.getPublicKey(), storeDir: './run/apps/fleet', keyFile: './run/fleet.key', allowedKeyFiles: ['./run/gateway.key'], kernelKeyFile: './run/fleet.kernel.pub' }) await runtime.start() kernel.allowKey(runtime.getKernelPublicKey()) await startGateway({ kernel, port: 3000, gatewayKeyFile: './run/gateway.key', apps: [{ name: 'fleet', appDir, keyFile: './run/fleet.key' }] }) ``` The runtime's Kernel key must be admitted before its controllers call the Kernel, through `kernel.allowKey()`, the Kernel's `auth.whitelist`, or the trusted-keys directory. [`startGateway()`'s apps reference](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md#apps) lists the validation errors, and [`@tetherto/mdk-app-runtime`](https://github.com/tetherto/mdk/blob/main/backend/core/app-runtime/README.md) the runtime options. `startGateway()` throws `ERR_GATEWAY_EXTRA_PLUGIN_DIRS_UNSUPPORTED` when passed `extraPluginDirs`. ### The `req` object A controller's only argument. The controller reference documents every field (`params`, `query`, `body`, `headers`, `_info`) and how it's assembled. ### The app's context module `require('@tetherto/mdk-gateway/plugin')` resolves, inside an app's controllers, to that app's own frozen context. The controller reference shows a controller building its own client from it: | Field | Type | Contains | | --- | --- | --- | | `config` | `object` | The `mdk-app.yaml` `config` defaults with the app's `spec.apps[].config` layered over them key-by-key, plus `kernelKey`, `kernelBootstrap` and the app's own `kernelKeyPair`, which is what `createMdkClient(config)` reads | | `logger` | `object` | The app runtime's logger, namespaced `mdk:app:` | | `onReady` | `function` | Registers a callback that runs once the app runtime is serving, or straight away if it already is. Errors thrown inside it are caught and logged, not fatal | ### Supplying per-app config That config isn't declared in `mdk-plugin.json`. Defaults live under `config` in `mdk-app.yaml`, and the stack spec overrides them per app with `spec.apps[].config` (`createAppRuntime({ config })` from code): ```yaml spec: apps: - name: custom-metrics package: ./apps/custom-metrics config: apiKey: ${METRICS_API_KEY} ``` Build a `lib/client.js` from it once per app and `require` that module from every controller that needs one — there is no per-request Kernel access to guard, only the client's own connect failures: `createMdkClient` connects on first use and memoizes the connection. A failure maps to `ERR_MDK_CLIENT_UNAVAILABLE` (or your own `opts.errorCode`) and resets so the next call retries — guard the call, not a null client: ```js try { return await mdkClient.pullTelemetry(deviceId, 'metrics') } catch (err) { if (err.message === 'ERR_MDK_CLIENT_UNAVAILABLE') throw new Error('ERR_KERNEL_UNREACHABLE') throw err } ``` ### Read hardware data Call the client directly for live device data — [`pullTelemetry`, `getCapabilities`, and `listWorkers`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md#client-methods) are documented with their return shapes in the client's own reference. A Worker is single-device, so a live fleet-wide total — hashrate across every miner on site, say — is the controller's own job: list every Worker, pull each device's live telemetry, and add the numbers up: ```js const { workers } = await mdkClient.listWorkers() const pulls = workers.flatMap((w) => (w.deviceIds || []).map(async (deviceId) => { const { metrics } = await mdkClient.pullTelemetry(deviceId, 'metrics') return metrics?.stats?.hashrate_mhs?.avg || 0 })) const totalHashrateMhs = (await Promise.all(pulls)).reduce((sum, v) => sum + v, 0) ``` [`demo/controllers/summary.js`](https://github.com/tetherto/mdk/blob/main/backend/apps/demo/controllers/summary.js) is the smallest worked example of this fan-out, worth starting from if you're authoring your own app. There is no separate Gateway-side store for historical or aggregated data, either. Fan [`pullWorkerTelemetry`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md#client-methods) out across every registered Worker and read the series from the Worker's own persisted tail-log: ```js const { workers } = await mdkClient.listWorkers() const results = await Promise.allSettled( workers.map((w) => mdkClient.pullWorkerTelemetry(w.workerId, { type: 'logs', key: 'stat-1D', tag: 't-miner', start, end })) ) ``` [`demo/controllers/history.js`](https://github.com/tetherto/mdk/blob/main/backend/apps/demo/controllers/history.js) is the smallest worked example — a single-file version with an optional per-device filter and a Kernel-unavailable fallback. Both live and historical calls are network calls through the client — guard both [the same way](#supplying-per-app-config). An unguarded `pullTelemetry` or `pullWorkerTelemetry` throws `ERR_MDK_CLIENT_UNAVAILABLE` straight through to the caller as an unhandled `500` the moment a Worker or the Kernel drops; neither path degrades more gracefully than the other. ### Send a command [`sendCommand`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md#client-methods) dispatches via the Kernel to the Worker that owns the device — the command must be declared in the Worker's [`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json). `controllers/command.js` above shows the pattern; return shape is `commandId`, `status`, `result`, `error`. ### Caching Add a `"cache"` array of dot-path strings to a route to enable request-level caching. The key is the route id plus those values, so a route whose answer depends on the caller adds an identity path such as `_info.user.userId`. On an `auth: true` route the [auth check](#auth-and-permissions) runs before the cache lookup. A forced refresh with `?overwriteCache=true` is honored only when the `authVerifier` returned an identity whose `perms` array holds `cache:overwrite`; any other request uses normal cache behavior. The manifest reference shows the field in a real manifest. ### Stream routes Add `"stream": true` to a route to own the raw `ServerResponse` instead of returning a plain value — for SSE or any other response Fastify shouldn't serialize. The manifest reference covers the mechanism and the handler's error behavior. [`backend/apps/agent`](https://github.com/tetherto/mdk/blob/main/backend/apps/agent/README.md) is a shipping example — its message route streams `text/event-stream` this way; see the [agent Gateway-deployment guide](/guides/agent/gateway-deployment) for the consumer side. ### Auth and permissions Mark a route `"auth": true` and the Gateway refuses it with `401 ERR_AUTH_UNAUTHORIZED` unless the `authVerifier` passed to `startGateway()` returns an identity for the request. The verifier runs before body parsing, validation, the cache and your controller. Its return value reaches the controller as `req._info`, so identity never comes from the request body. Routes without `"auth": true` are served to any caller that passes the Host check. [The Gateway's security model](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) lists the rest of the defaults. The Gateway ships no identity layer. Supply one in the verifier: ```js const { validateToken } = require('./lib/my-identity-layer') await startGateway({ kernel, gatewayKeyFile: './run/gateway.key', apps: [{ name: 'fleet', appDir, keyFile: './run/fleet.key' }], // route is { id, method, path, plugin, permissions } from the manifest authVerifier: async (request, route) => { const token = request.headers.authorization?.replace('Bearer ', '') if (!token) return false const { email, permissions } = await validateToken(token) if (!(route.permissions || []).every(p => permissions.includes(p))) { throw Object.assign(new Error('ERR_AUTH_PERM_DENIED'), { statusCode: 403 }) } return { user: { userId: email }, perms: permissions } } }) ``` The Gateway does not interpret the manifest `"permissions"` field itself; it hands it to the verifier as `route.permissions`. The controller then reads the verified identity: ```js module.exports = async function protectedRoute (req) { const { user, perms } = req._info // Pass user and perms into Kernel yourself (for example as authPerms). Do not take them from req.body. } ``` The Gateway forwards that identity to the app runtime with the request, so `req._info` is set in the app's process too. A controller's return value always goes out as `200`, as JSON: it crosses from the app runtime to the Gateway serialized, so return plain data (a `Buffer` arrives as `{ type: 'Buffer', data: [...] }`; send bytes from a `stream: true` route). A thrown error is shaped by [`errorResponse`](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/workers/lib/plugin-adapter.js): its status comes from `err.statusCode` when it is an integer 400 or higher, and falls back to `400` for an `ERR_*` error and `500` for anything else, which is a bug rather than a bad request. Attach `.statusCode` to get a specific code, the way `protectedRoute` does here; the bundled agent app uses this same pattern for its 503, 404, and 409 responses. The body is `{ statusCode, error, message }`, and `message` carries the error's own text for `ERR_*` codes and for Fastify's own 4xx errors (validation, content type, body size); any other message is replaced with the status text and logged. ### Manifest validation errors The app runtime validates the route table and every handler at startup, and the Gateway the route table, and each throws if anything is wrong — see the loader's error codes for the full list. ## Next steps - Watch [an app answer an HTTP request with live fleet data](/tutorials/see-an-app-serve-data) in about five minutes - Try the [live site backend example](/guides/deployment/run-all-workers-site) for a complete worked app with three routes: a live site overview, a historical series, and a command endpoint running under PM2 or Docker - Build the [minimal dashboard tutorial](/tutorials/build-a-dashboard) — end-to-end worked example of the single-app + controller pattern - Read the [demo app](https://github.com/tetherto/mdk/blob/main/backend/apps/demo/README.md) for the smallest complete worked example of both fan-out patterns above - Understand [how Workers declare their data](/guides/workers/build-a-worker) via [`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) — what [`mdkClient`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md#client-methods) reads and [`sendCommand`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md#client-methods) dispatches - See the full manifest and controller reference - Review the [Gateway API and config](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) - Follow how a request travels and who Kernel admits - Secure the site with the [security blueprint](/guides/security) # Run the Gateway (/guides/gateway/run) ## Overview This guide covers three ways to run the Gateway: programmatically via `startGateway()` (the standard production path), connected to a remote Kernel over HRPC (cross-host deployments), and as a standalone process from the source tree (for contributors). If Gateway, Kernel, or plugin are unfamiliar, read [terminology](/reference/glossary) first. For a deeper explanation of what the Gateway owns and how it connects to Kernel, read the [Gateway concept page](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md). ## Prerequisites - [Node.js](https://nodejs.org/) >=24 (LTS) - npm 11 [(< 12)](/reference/environment) - Commands are run from the repository root - A Kernel instance running and reachable, or `kernelKey: false` to start without a Kernel connection (development only) ### Programmatic path Most teams embed `startGateway()` in their own Node.js application rather than running the Gateway as a separate process. This is the standard production path. ```js const { getKernel, startGateway } = require('@tetherto/mdk-core') const kernel = await getKernel() const server = await startGateway({ kernel, port: 3000 }) // HTTP server is up at http://localhost:3000 ``` The Gateway ships no identity layer. Routes a manifest marks `"auth": true` answer `401` until you pass `startGateway()` an `authVerifier` backed by your own identity layer, as [auth and permissions](/guides/gateway/apps#auth-and-permissions) describes; every other route is served to any caller. Only `localhost`, `127.0.0.1` and `[::1]` on the bound port pass the Host check by default, so list the names your clients use in `allowedHosts` when you bind to a LAN address. The full configuration reference, including all `startGateway()` options, is in the [Gateway API reference](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md). ### Cross-host path (HRPC) Use this path when Kernel runs on a separate host. Pass the Kernel HRPC listener public key to `startGateway()` instead of a Kernel instance. (On a single host, neither is needed: `startGateway()` reads the key from the well-known key file that `getKernel()` publishes — see the [key resolution order](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md).) #### 2.1 Obtain the Kernel listener key On the host running Kernel, start Kernel and print its public key: ```js const { getKernel } = require('@tetherto/mdk-core') const kernel = await getKernel() console.log('Kernel listener key:', kernel.getPublicKey().toString('hex')) ``` Share that hex string with the Gateway host. #### 2.2 Start the Gateway with `kernelKey` ```js const { startGateway } = require('@tetherto/mdk-core') const server = await startGateway({ kernelKey: '', port: 3000 }) ``` Kernel's allowlist `auth.whitelist` is fail-closed: empty admits nobody. `startGateway({ kernel })` admits the Gateway's key on an in-process Kernel, and a same-machine Kernel admits it from the shared trusted-keys dir. For a Kernel on another host, add the Gateway's DHT public key (`status.rpcClientKey` in its status file, stable across restarts) to Kernel's allowlist — see the [Gateway concept page](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) and [`opts.kernelKey` reference](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md). ### Standalone path To run the Gateway directly from the source tree without embedding it: ```bash cd backend/core/gateway npm install npm run dev ``` For production mode: ```bash npm start ``` The standalone path is intended for contributors working on the Gateway itself. For application development, embed `startGateway()` in your own project rather than running it standalone. ## Next steps - [Add routes with the plugin system](/guides/gateway/apps) - [Review all configuration options](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) - Understand the [extension model, auth design, and Kernel connection](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) - Choose a [deployment shape](/guides/deployment) # Tear down MDK services (/guides/gateway/teardown) ## Overview As a library, MDK leaves your process's signals alone: `getKernel()` and `startGateway()` install `SIGINT`/`SIGTERM` handlers only when you pass `handleSignals: true`, which a standalone process does so that Ctrl+C triggers a clean teardown with no extra code. This guide covers the three ways to tear down: - [Signal-driven teardown](#automatic-teardown-with-getkernel) - [Explicit teardown](#explicit-teardown-in-tests-or-scripted-runs) - [Custom signal handling](#custom-signal-handling-with-onshutdown) ## Prerequisites - Familiarity with the [Gateway](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) - MDK [installed and a working boot sequence](/guides/gateway/run) ### Automatic teardown with `getKernel()` `getKernel({ handleSignals: true })` registers `SIGINT`/`SIGTERM` handlers that drain the cleanup chain, stop the Kernel and exit the process. Without the option no handler is installed. A Gateway started with `opts.kernel` is chained into the cleanup sequence automatically. Workers are **not** auto-chained: a Worker's boot function has no `opts.kernel`, so push its `stop()` onto `kernel._cleanup` yourself if you want Kernel shutdown to cascade to it: ```js const { getKernel, startGateway } = require('@tetherto/mdk-core') const { startAvalonWorker } = require('@tetherto/mdk-worker-avalon') const kernel = await getKernel({ handleSignals: true }) const { runtime, stop } = await startAvalonWorker({ workerId: 'avalon-rack-1', model: 'a1346', storeDir: './data/avalon' }) await kernel.registerWorker(runtime.getPublicKey()) kernel._cleanup.push(stop) // cascade Worker shutdown from Kernel await startGateway({ kernel, port: 3000 }) // Press Ctrl+C: MDK stops Gateway, then the Worker, then Kernel. ``` See [`getKernel` API reference](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md#getkernelopts--promisekernelmanager) and the [Workers discovery model](https://github.com/tetherto/mdk/blob/main/backend/workers/README.md) for the same-process lifecycle rules. ### Explicit teardown in tests or scripted runs Short-lived processes — integration tests, one-shot scripts — and embedders that own their signals (the default) call `shutdown(kernel)` directly to drain the full cleanup chain. Pass the `kernel` object returned by `getKernel()`; passing a server object stops only the Gateway. ```js const { getKernel, startGateway, shutdown } = require('@tetherto/mdk-core') const kernel = await getKernel() await startGateway({ kernel }) // … run assertions or perform work … await shutdown(kernel) // stops Gateway (chained), then stops Kernel ``` See [`shutdown` API reference](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md#shutdownhandle--promisevoid). ### Custom signal handling with `onShutdown` Use `onShutdown` when you need to close resources outside an MDK boot object — for example, a database connection or a log buffer. ```js const { onShutdown } = require('@tetherto/mdk-core') onShutdown(async () => { await db.close() await logger.flush() }, { forceMs: 5000 }) ``` See [`onShutdown` API reference](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md#onshutdowncleanupfn-opts--handler). ## What just happened 1. **Automatic chain**: `getKernel({ handleSignals: true })` and `startGateway({ kernel })` wire themselves into `kernel._cleanup` so a single signal stops Kernel and Gateway in order; push a Worker's `stop()` onto `kernel._cleanup` yourself to fold it into the same chain. 2. **Explicit drain**: `shutdown(kernel)` gives you the same ordered teardown on demand, without a signal. 3. **Custom hooks**: `onShutdown(fn)` lets you attach cleanup logic outside the MDK object hierarchy. ## Next steps - [`@tetherto/mdk-core` README](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md): full API reference - [Run the Gateway](/guides/gateway/run) # Write actions (/guides/gateway/write-actions) ## Overview This guide shows how to expose approval-gated write actions as Gateway routes, then submit, review, approve, reject, or cancel them from a React app. MDK ships no React hooks for actions: the app calls its own routes with `useQuery` / `useMutation`. ## Prerequisites - The [Gateway is running](/guides/gateway/run) with an [actions app mounted](#create-an-actions-app) - Your actions routes are marked `"auth": true` and the Gateway runs with an `authVerifier`, since [an unprotected route is reachable by anyone](/guides/gateway/apps#auth-and-permissions) - Your controllers pass the caller's device-family write permissions (`miner:w`, `container:w`) to Kernel as `authPerms`, which Kernel requires before resolving or approving a write - If present, your React app is wrapped in [``](https://github.com/tetherto/mdk/blob/main/ui/packages/react-adapter/README.md#surface) Create the [actions app](#create-an-actions-app) first, then [call its routes](#call-the-routes-from-a-react-app) from the UI. ## Create an actions app To enable approval-gated writes, create an app that exposes HTTP routes for the write-action workflow. Each route should call the corresponding method on the app's own `mdkClient` (built from `require('@tetherto/mdk-gateway/plugin')`, [as any app does](/guides/gateway/apps)). The paths shown below (`/auth/actions*`) are illustrative examples. You may use any path structure that fits your app's routing pattern. ### Required routes | Method | Example Path | `mdkClient` method | Purpose | |--------|--------------|------------------|---------| | `GET` | `/auth/actions` | `queryActions()` | Query actions by lifecycle state (voting/ready/executing/done) | | `POST` | `/auth/actions/voting` | `pushAction()` | Submit a single action for approval | | `POST` | `/auth/actions/voting/batch` | `pushActionsBatch()` | Submit multiple actions for approval | | `PUT` | `/auth/actions/voting/:id/vote` | `voteAction()` | Cast approval/rejection vote on an action | | `DELETE` | `/auth/actions/voting/cancel` | `cancelActionsBatch()` | Cancel pending actions by IDs | ### App structure Start from `mdk create app actions` and replace its summary route with one controller per route: ```text apps/actions/ ├── mdk-app.yaml └── plugin/ ├── mdk-plugin.json ├── lib/client.js └── controllers/ ├── query.js ├── push.js ├── push-batch.js ├── vote.js └── cancel.js ``` ### Example controller (push.js) ```javascript 'use strict' const mdkClient = require('../lib/client') module.exports = async function pushAction (req) { // The identity your authVerifier returned for this request (route marked "auth": true) const { user: { userId: voter }, perms: authPerms } = req._info return await mdkClient.pushAction({ query: req.body.query, // Device query/selector action: req.body.action, // Action name from worker contract params: req.body.params, // Action parameters voter, // Current user identifier authPerms // User's permissions (e.g., ['miner:w']) }) } ``` [`lib/client.js`](/guides/gateway/apps) builds the client once for the whole app, per the pattern in the app authoring guide. ### Manifest example (mdk-plugin.json) ```json { "name": "actions", "version": "1.0.0", "description": "Approval-gated write action APIs", "routes": [ { "id": "actions.push", "handler": "./controllers/push.js", "auth": true, "http": { "method": "POST", "path": "/auth/actions/voting", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["query", "action", "params"], "properties": { "query": { "type": "object" }, "action": { "type": "string" }, "params": { "type": "array" } } } } } } }, "description": "Submit a write action for approval", "safety": "Stage-only: does not execute until approved" } ] } ``` `"auth": true` makes the Gateway answer `401` unless the `authVerifier` vouches for the caller, which matters more here than on a read route because it stages fleet-changing writes. The controller still checks that the identity in `req._info` holds the write permission. [Auth and permissions](/guides/gateway/apps#auth-and-permissions) covers the patterns. ### Mount the app Declare it in `mdk.yaml` and start the stack with `mdk run`. The React hooks call `/auth/actions*` at the Gateway root, so mount the app at `/` rather than the default `/apps/`: ```yaml spec: apps: - name: actions package: actions mount: / ``` Running it from your own code instead goes through `startGateway({ apps })` and `createAppRuntime()`, as [starting apps from code](/guides/gateway/apps#start-apps-from-code) shows. The Kernel does not enforce the `permissions` an app declares in `mdk-app.yaml` yet: the app runtime's Kernel key is admitted with full access. The controller's own check on `req._info` is what stands between a caller and a staged write. ## Call the routes from a React app Read the queue with `useQuery` and send writes with `useMutation`, both re-exported by [`@tetherto/mdk-react-adapter`](https://github.com/tetherto/mdk/blob/main/ui/packages/react-adapter/README.md). Invalidate the queue after each write so the list refreshes: ```tsx const QUEUE_KEY = ["actions", "voting"]; function PendingActions() { const { apiBaseUrl } = useMdkContext(); const queryClient = useQueryClient(); const { data: pending = [] } = useQuery({ queryKey: QUEUE_KEY, // Your GET route decides how it maps the request onto `queryActions()` and what it returns; // this example assumes it returns the voting actions as an array. queryFn: () => fetch(`${apiBaseUrl}/auth/actions`).then((r) => r.json()), refetchInterval: 10_000, }); const vote = useMutation({ mutationFn: ({ id, approve }: { id: string; approve: boolean }) => fetch(`${apiBaseUrl}/auth/actions/voting/${id}/vote`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ approve }), }), onSuccess: () => queryClient.invalidateQueries({ queryKey: QUEUE_KEY }), }); return pending.map((action: { id: string }) => ( )); } ``` Submit (`POST /auth/actions/voting`) and cancel (`DELETE /auth/actions/voting/cancel`) follow the same `useMutation` shape. Approved actions become command requests once the vote thresholds are met; the item then leaves the voting queue. For complete mdk-client method signatures and protocol details, see the [mdk-client README](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) and [Kernel actions integration tests](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/tests/integration/actions.test.js). ## Next steps - Understand the [approval-gated write architecture](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#actionmanager) — including how approved actions become normal command requests - Protect your routes with [controller-level auth and permission checks](/guides/gateway/apps#auth-and-permissions) - Build routes with the [app route table format](/guides/gateway/apps), including caching and manifest fields - Run integration coverage: [`backend/core/kernel/tests/integration/actions.test.js`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/tests/integration/actions.test.js) # Run a miner Worker (/guides/miners) ## Overview MDK drives each miner brand through its own Worker. These guides are task-focused and **independent**; you only need the one for the hardware you operate. If Kernel, Worker, manager, or thing are unfamiliar, read [terminology](/reference/glossary) first. ## Pick your hardware The authoritative model list for every Worker is the generated [supported-hardware catalogue](/reference/supported-hardware). For example, you may: - [Run an Antminer Worker](/guides/miners/run-antminer-worker) - [Run a Whatsminer Worker](/guides/miners/run-whatsminer-worker) - [Run an Avalon Worker](/guides/miners/run-avalon-worker) ## Prerequisites Every guide assumes: - [Node.js](https://nodejs.org/) >=24 (LTS) - npm 11 [(< 12)](/reference/environment) - Dependencies installed (`npm run setup` from the repo root) - Commands are run from the repo root - Outbound UDP access to the HyperDHT bootstrap nodes For the mock/development path: - No physical miner is required - The runnable example for your model starts the bundled mock device and registers it HRPC relies on HyperDHT for peer connectivity. Use the [network requirements and checks](/guides/miners/troubleshooting) if an example stalls before printing the Kernel key. For the deployment path: - A Node.js service or script in your deployment that runs the MDK Worker and registers devices - A supported miner reachable from the machine or container running the Worker - Access to the miner's native API and credentials, if that API requires them - The Worker's `USAGE.md` for the exact `registerThing` options ## Next steps - Browse [supported hardware](/reference/supported-hardware) - New to the moving parts? Read [terminology](/reference/glossary) (Kernel, Worker, manager, thing, mock) - If an example does not start or a mock port is busy, use [troubleshooting](/guides/miners/troubleshooting) - Drive the registered device from a dashboard: [run a mining site end to end](/tutorials/run-a-site) # Run an Antminer Worker (/guides/miners/run-antminer-worker) ## Overview Antminer support ships as the external [`antminer-mdk-worker`](https://github.com/bitmain-official/antminer-mdk-worker) contract plugin, maintained by Bitmain. It's a plain `mdk-contract.json` plus handler files, with no `startAntminerWorker` export, no provisioning store, no alerts/stats templates, and no model allowlist: compatibility comes from the firmware capabilities each miner reports. You host it yourself on [`WorkerRuntimeV2`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js) through a small adapter; this repo ships a working one to copy from ([`examples/mvp-site/backend/antminer-adapter.js`](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/backend/antminer-adapter.js)). ## Prerequisites Review the [common deployment prerequisites](/guides/miners#prerequisites) before you start. Deployment-specific requirements: - The [`antminer-mdk-worker`](https://github.com/bitmain-official/antminer-mdk-worker) package added as a dependency (it's distributed as a git dependency, not on the npm registry — see [its own README](https://github.com/bitmain-official/antminer-mdk-worker) for the exact version/ref to pin) - A Node.js service or script in your deployment that constructs the adapter below and registers the resulting Worker - An Antminer running stock firmware, reachable from the machine or container running the Worker - The miner's HTTP CGI API reachable, port `80` by default - HTTP Digest credentials for the miner. The factory default is `root`/`root`; use your site's configured credentials ### Development
Run against a mock The plugin ships its own stateful mock at `antminer-mdk-worker/mock/cgi-server`. It enforces real digest authentication, serves every CGI endpoint the Worker uses, and applies command writes to its state: ```js const amMock = require('antminer-mdk-worker/mock/cgi-server') const mock = amMock.createServer({ port: 14080, host: '127.0.0.1', username: 'root', password: 'root' }) await mock.ready // ... await mock.close() ``` [`examples/mvp-site`](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/backend/antminer-adapter.js) boots one mock per seed device listed under `antminers` in `config/devices.json` and points the adapter's devices at them. Read `startAntminerMocks` alongside `bootAntminerWorker` in that example's `backend/site.js` for the full wiring. `cd examples/mvp-site && npm run setup:example && npm start` boots the whole stack — Kernel, mocks, and the Antminer Worker included.
### Connect a miner #### 2.1 Write the adapter `antminer-mdk-worker` has no boot helper of its own. Construct a [`WorkerRuntimeV2`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js) pointed at the package directory, translating your seed device list into the `{ deviceId, config }` shape it expects: ```js const path = require('path') const { WorkerRuntimeV2 } = require('@tetherto/mdk-worker') const PKG_DIR = path.dirname(require.resolve('antminer-mdk-worker/package.json')) async function startAntminerWorker (opts) { const devices = (opts.seedDevices || []).map((seed) => ({ deviceId: seed.info?.serialNum || seed.id, config: { ...(opts.type ? { type: opts.type } : {}), ...seed.opts } })) const runtime = new WorkerRuntimeV2(PKG_DIR, { workerId: opts.workerId, kernelTopic: opts.kernelTopic || null, storeDir: opts.storeDir, devices }) await runtime.start() return { runtime, seeded: devices.length, stop: () => runtime.stop() } } module.exports = { startAntminerWorker } ``` This is the adapter [`examples/mvp-site/backend/antminer-adapter.js`](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/backend/antminer-adapter.js) ships, trimmed of its input validation and debug logging — copy the real file rather than this excerpt for a production deployment. #### 2.2 Register your miner Add this to the Node.js service or script that runs your Worker. The snippet seeds one Antminer device; replace the example IP address and credentials with your miner's values: ```js const { getKernel } = require('@tetherto/mdk-core') const { startAntminerWorker } = require('./antminer-adapter') const kernel = await getKernel() const worker = await startAntminerWorker({ workerId: 'antminer-rack-1', storeDir: './store/antminer-rack-1', type: 'miner-am-s21', seedDevices: [{ info: { serialNum: 'AM-001' }, opts: { address: '192.168.1.10', port: 80, username: 'root', password: process.env.ANTMINER_PASSWORD } }] }) await kernel.registerWorker(worker.runtime.getPublicKey()) ``` Device `config` fields the plugin reads: | Field | Required | Description | | --- | --- | --- | | `address` | Yes | Miner IPv4 address or hostname. `host` is accepted as an alias | | `username` | No | HTTP Digest username. Defaults to `root` | | `password` | No | HTTP Digest password. Defaults to `root` — override with the real credentials | | `port` | No | HTTP API port. Defaults to `80` | | `timeout` | No | Per-request timeout in milliseconds. Defaults to `10000` | | `type` | No | Device type tag such as `miner-am-s21xp_hyd`. Resolves the rated J/TH reported in `snap` | | `nominalEfficiencyWThs` | No | Overrides the nominal efficiency table for `snap` | | `conf` | No | Configured pools (`conf.pools`, max 3), timeouts and size limits | Make sure each miner's IP is reachable from the machine or container running the Worker before registering. Commands act on physical hardware. Prioritize thermal safety. The device list is fixed at construction — there is no `registerThing`/`updateThing`/`forgetThings` equivalent. Adding, updating, or removing a device means editing `seedDevices` and restarting the Worker. The [`antminer-mdk-worker`](https://github.com/bitmain-official/antminer-mdk-worker) README and USAGE document the plugin's telemetry channels, commands, failure semantics, and live-device test scripts in full; the shared [install pattern](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/install-pattern.md) covers the broader deployment mechanics.
## Troubleshooting The reference deployment is [`examples/mvp-site`](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/backend/antminer-adapter.js)'s full stack: `npm start` prints `MDK_READY worker-antminer devices=` once the Antminer Worker is up. An unreachable miner doesn't fail at load time: the Worker reports the device online and the failure surfaces inside the telemetry channel or command response that touched the network. If the Worker doesn't come up, or a mock port is already in use, follow [miner troubleshooting](/guides/miners/troubleshooting). ## Next steps - Understand the [deployment topologies](/guides/deployment) for running the Worker service - Review [`antminer-mdk-worker`](https://github.com/bitmain-official/antminer-mdk-worker)'s own [`mdk-contract.json`](https://github.com/bitmain-official/antminer-mdk-worker/blob/4fd776b4a048a1bc8284ec2eceed82b4631f960c/mdk-contract.json) for telemetry units, command shapes, and error codes — it isn't vendored into this repo # Run an Avalon Worker (/guides/miners/run-avalon-worker) ## Overview This page details how to run the Canaan Avalon Worker. Select the development (mock) or real-device path. ## Prerequisites Review [common deployment prerequisites](/guides/miners#prerequisites) before you start. Deployment-specific requirements: - A Node.js service or script in your deployment that runs the MDK Worker and registers devices - A supported Avalon device reachable from the machine or container running the Worker - The miner API reachable over the native CGMiner TCP API, typically port `4028` - A `password` for each device: the CGMiner wire protocol itself does not authenticate, but the Worker's own config validation requires this field ### Development
Run against a mock The Avalon Worker ships a mock A1346 that answers the CGMiner API with canned data. Run the mock in one terminal and the Worker in a second, both from the repository root. 1. Start the mock on the CGMiner port: ```bash npm run mock a1346 4028 ``` 2. Save this script as `avalon-dev.js` in the repository root. It is the boot call from Connect a miner, pointed at `127.0.0.1`: ```js 'use strict' const os = require('os') const path = require('path') const { getKernel } = require('@tetherto/mdk-core') const { startAvalonWorker } = require('@tetherto/mdk-worker-avalon') const main = async () => { const kernel = await getKernel() const worker = await startAvalonWorker({ workerId: 'avalon-dev', model: 'a1346', storeDir: path.join(os.tmpdir(), 'mdk-dev', 'avalon'), allowedKeys: [kernel.getClientPublicKey()], seedDevices: [{ info: { container: 'dev', serialNum: 'AV-DEV-001' }, opts: { address: '127.0.0.1', port: 4028, password: 'admin' } }] }) await kernel.registerWorker(worker.runtime.getPublicKey()) console.log('Kernel HRPC key:', kernel.getPublicKey().toString('hex')) console.log('Device:', worker.services.provisioning.listDeviceIds()[0]) console.log('Ctrl+C to stop.') } main().catch((err) => { console.error(err); process.exit(1) }) ``` 3. Run the script in a second terminal: ```bash node avalon-dev.js ``` Expected output, with your own key and device ID: ```text Kernel HRPC key: e9c1f37dc46f5a6a08fcd6d75a697477491e82b9282d9f2d72bee2ab584d0c70 Device: 17aad636-6ebb-4cb0-8865-c7a200625dfe Ctrl+C to stop. ``` The Kernel then pulls live telemetry (hashrate, power, temperature) from the mock through the Worker. The mock's options are in [USAGE.md](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/avalon/USAGE.md).
### Connect a miner #### 2.1 Confirm the model Avalon ships one model family today, `a1346` — confirm this against the [USAGE.md](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/avalon/USAGE.md) as new models are added. #### 2.2 Register your miner Avalon devices use the native CGMiner TCP API on port 4028. The wire protocol itself does not authenticate, but the Avalon Worker Plugin's own config validation rejects a device with no `password`. Add this code to the Node.js service or script that runs the MDK Worker in your deployment. The snippet shows the minimum boot call seeding one Avalon device; replace the example IP address and password with your miner's values: ```js const { getKernel } = require('@tetherto/mdk-core') const { startAvalonWorker } = require('@tetherto/mdk-worker-avalon') const kernel = await getKernel() const worker = await startAvalonWorker({ workerId: 'avalon-rack-1', model: 'a1346', storeDir: './store/avalon-rack-1', allowedKeys: [kernel.getClientPublicKey()], seedDevices: [{ info: { container: 'site-1', serialNum: 'AV-001' }, opts: { address: '192.168.1.30', port: 4028, password: 'admin' } }] }) await kernel.registerWorker(worker.runtime.getPublicKey()) ``` `allowedKeys` lists the RPC callers the Worker accepts. The Worker refuses to start without it (`ERR_WORKER_ALLOWED_KEYS_REQUIRED`), so pass the Kernel's client key. Make sure each miner's IP is reachable from the machine or container running the Worker before registering. Commands act on physical hardware — prioritize thermal safety. `seedDevices` only seeds a fresh, empty `storeDir` — once persisted, the device set survives restarts on its own. To add a device to an already-running fleet, send the `registerThing` command to the live Worker instead: ```js const { createMdkClient } = require('@tetherto/mdk-client') const client = createMdkClient({ kernelKey: kernel.getPublicKey() }) await client.connect() await client.sendWorkerCommand('avalon-rack-1', null, 'registerThing', { id: 'AV-002', info: { container: 'site-1', serialNum: 'AV-002' }, opts: { address: '192.168.1.31', port: 4028, password: 'admin' } }) ``` `registerThing` persists the device config immediately, but the running Worker does not pick it up until it is stopped and restarted (`await worker.stop()`, then call `startAvalonWorker` again with the same `storeDir` and no `seedDevices`) — there is no hot-add. Before running in a deployment, generate the Worker config (`common.json` for Worker identity, `base.thing.json` for device defaults and per-model alert thresholds): ```bash cd backend/workers/miners/avalon ./setup-config.sh ``` For the full `seedDevices`/`registerThing` option reference and the mock `createServer` options, see the Worker's [USAGE.md](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/avalon/USAGE.md) and the shared [install pattern](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/install-pattern.md).
## Troubleshooting A working development run of `avalon-dev.js` prints `Kernel HRPC key:` and `Device:`, then stays running until Ctrl+C. If the script does not print both values, or if `npm run mock` reports port `4028` already in use, follow [miner troubleshooting](/guides/miners/troubleshooting). ## Next steps - Decide how to run the Worker service — [Deployment topologies](/guides/deployment) - Review telemetry units, command shapes, and error codes — [`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/avalon/plugin/mdk-contract.json) # Run a Whatsminer Worker (/guides/miners/run-whatsminer-worker) ## Overview Whatsminer support ships as the external [`whatsminer-mdk-worker`](https://github.com/whatsminer/whatsminer-mdk-worker) contract plugin — a plain `mdk-contract.json` plus handler files, with no `startWhatsminerWorker` export, no provisioning store, no alerts/stats templates, and no model validation. You host it yourself on [`WorkerRuntimeV2`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js) through a small adapter; this repo ships a working one to copy from ([`examples/mvp-site/backend/whatsminer-adapter.js`](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/backend/whatsminer-adapter.js)). ## Prerequisites Review the [common deployment prerequisites](/guides/miners#prerequisites) before you start. Deployment-specific requirements: - The [`whatsminer-mdk-worker`](https://github.com/whatsminer/whatsminer-mdk-worker) package added as a dependency (it's distributed as a git dependency, not on the npm registry — see [its own README](https://github.com/whatsminer/whatsminer-mdk-worker) for the exact version/ref to pin) - A Node.js service or script in your deployment that constructs the adapter below and registers the resulting Worker - A supported Whatsminer device reachable from the machine or container running the Worker - The miner API reachable over encrypted TCP: port `4028` for API v2 (the default) or `4433` for API v3 - The Whatsminer API password — the plugin negotiates a session token from it; there is no separate username ### Development
Run against a mock The plugin ships its own mock at `whatsminer-mdk-worker/mock/api-v3-server`. [`examples/mvp-site`](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/backend/whatsminer-adapter.js) boots one mock per seed device and points the adapter's devices at them — read `startMocks` alongside `startWhatsminerWorker` in that example's `backend/site.js` for the full wiring (mock lifecycle, port assignment, device seeding all come from `config/devices.json`). There is no minimal single-file runnable example for Whatsminer anymore (the old repo-root example shipped against the retired in-repo package); the mvp-site example stack is the reference implementation. `cd examples/mvp-site && npm run setup:example && npm start` boots the whole stack — Kernel, mocks, and the Whatsminer Worker included — against seed data in `config/devices.json`.
### Connect a miner #### 2.1 Write the adapter `whatsminer-mdk-worker` has no boot helper of its own — construct a [`WorkerRuntimeV2`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js) pointed at the package directory, translating your seed device list into the `{ deviceId, config }` shape it expects: ```js const path = require('path') const { WorkerRuntimeV2 } = require('@tetherto/mdk-worker') const PKG_DIR = path.dirname(require.resolve('whatsminer-mdk-worker/package.json')) async function startWhatsminerWorker (opts) { const devices = (opts.seedDevices || []).map((seed) => ({ deviceId: seed.info?.serialNum || seed.id, config: { ...seed.opts } })) const runtime = new WorkerRuntimeV2(PKG_DIR, { workerId: opts.workerId, kernelTopic: opts.kernelTopic || null, storeDir: opts.storeDir, devices }) await runtime.start() return { runtime, seeded: devices.length, stop: () => runtime.stop() } } module.exports = { startWhatsminerWorker } ``` This is the adapter [`examples/mvp-site/backend/whatsminer-adapter.js`](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/backend/whatsminer-adapter.js) ships, trimmed of its input validation and debug logging — copy the real file rather than this excerpt for a production deployment. #### 2.2 Register your miner Add this to the Node.js service or script that runs your Worker. The snippet shows the minimum boot call seeding one Whatsminer device; replace the example IP address and password with your miner's values: ```js const { getKernel } = require('@tetherto/mdk-core') const { startWhatsminerWorker } = require('./whatsminer-adapter') const kernel = await getKernel() const worker = await startWhatsminerWorker({ workerId: 'whatsminer-rack-1', storeDir: './store/whatsminer-rack-1', seedDevices: [{ info: { serialNum: 'WM-001' }, opts: { address: '192.168.1.10', port: 4028, password: 'admin' } }] }) await kernel.registerWorker(worker.runtime.getPublicKey()) ``` Make sure each miner's IP is reachable from the machine or container running the Worker before registering. Commands act on physical hardware. Prioritize thermal safety. The device list is fixed at construction — there is no `registerThing`/`updateThing`/`forgetThings` equivalent. Adding, updating, or removing a device means editing `seedDevices` and restarting the Worker. The [`whatsminer-mdk-worker`](https://github.com/whatsminer/whatsminer-mdk-worker) README documents the plugin's own `mdk-contract.json`, supported models, and connection options in full; the shared [install pattern](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/install-pattern.md) covers the broader deployment mechanics.
## Troubleshooting There is no minimal single-file development example to check readiness output against — the reference deployment is [`examples/mvp-site`](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/backend/whatsminer-adapter.js)'s full stack (`npm start` prints `MDK_READY worker devices=` once the Whatsminer Worker is up). If the Worker does not come up, or a mock port is already in use, follow [miner troubleshooting](/guides/miners/troubleshooting). ## Next steps - Understand the [deployment topologies](/guides/deployment) for running the Worker service - Review [`whatsminer-mdk-worker`](https://github.com/whatsminer/whatsminer-mdk-worker)'s own `mdk-contract.json` for telemetry units, command shapes, and error codes — it isn't vendored into this repo # Troubleshoot miner Workers (/guides/miners/troubleshooting) ## Overview This page covers the mock/development examples used by the [Antminer](/guides/miners/run-antminer-worker), [Whatsminer](/guides/miners/run-whatsminer-worker), and [Avalon](/guides/miners/run-avalon-worker) miner guides. The examples start a bundled mock miner, start a Kernel, register one device, print the identifiers you need, and keep running until you stop them. ## Expected output A working example prints a Kernel key and a registered device ID: ```text Kernel HRPC key: Device: Ctrl+C to stop. ``` If you do not see both `Kernel HRPC key:` and `Device:`, use the following checks. ## Find the right port Mock examples and real miners use different sources for ports. ### Mock examples An in-repo mock listens on the port you pass to `npm run mock`, and the Worker reaches it through the `port` in that device's `seedDevices` entry. The two must match: the [Avalon mock run](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/avalon/USAGE.md#run-against-a-mock) uses `4028` for both. Run `npm run mock` with no arguments to list every mock device and its types. Antminer and Whatsminer follow a different shape — they're the external [`antminer-mdk-worker`](https://github.com/bitmain-official/antminer-mdk-worker) and [`whatsminer-mdk-worker`](https://github.com/whatsminer/whatsminer-mdk-worker) plugins, not in-repo packages with a bundled mock. Their mock ports come from whatever deployment config seeds them; `examples/mvp-site`'s `config/devices.json` (`antminers` and `miners`) and `config/site.deploy.json` are the reference. The cross-worker manifest also records the expected mock type and default port for each variant: [workers manifest](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/workers-manifest.yaml). ### Real miners Real devices use their native APIs: - Antminer: HTTP, usually port `80`, with digest-auth credentials. - Whatsminer: encrypted TCP, port `4028` for API v2 (the default) or `4433` for API v3 (auto-detected from the port), with the API password. - Avalon: CGMiner TCP API, usually port `4028`, with no username or password. Before registering a real miner, confirm the miner is reachable from the machine or container running the Worker. ## Antminer or Whatsminer example installs an unexpected dependency [`antminer-mdk-worker`](https://github.com/bitmain-official/antminer-mdk-worker) and [`whatsminer-mdk-worker`](https://github.com/whatsminer/whatsminer-mdk-worker) are git dependencies, not versioned npm packages, and their `package.json` entries carry no pinned commit. Only the committed `package-lock.json` resolves it to a specific commit, so a plain `npm install` with a missing or discarded lockfile, or with a lockfile-regenerating command like `npm update`, can silently pull a different commit than the one already tested. If `npm install` changes `package-lock.json` unexpectedly, or `git diff` shows the `antminer-mdk-worker` or `whatsminer-mdk-worker` entry's `resolved` commit changed, reinstall from the committed lockfile instead. `examples/mvp-site` is an npm workspace, not a standalone project — it has no lockfile of its own, so run this from the repository root: ```bash npm ci ``` `npm ci` fails on a lockfile/`package.json` mismatch instead of drifting. Separately, `npm ls crypto-js --prefix examples/mvp-site` or an `npm audit` run may show `crypto-js` resolving to `mdk-crypto-lib`, a local file dependency, rather than the real npm package. This is expected: `whatsminer-mdk-worker` still declares a dependency on the deprecated `crypto-js`, and MDK overrides it at install time with a drop-in replacement built on Node's own `node:crypto`. It is not a broken install. ## Clean up a mock port If an example exits with `EADDRINUSE` or says a port is already in use, find the process using that port: ```bash lsof -nP -iTCP: -sTCP:LISTEN ``` Replace `` with the mock port for your example. The output includes a process ID (`PID`). If the process is an old miner mock or example that you no longer need, stop it: ```bash kill ``` Run `lsof` again to confirm the port is free before restarting the example. ## Example does not print a Kernel key Same-process examples register Worker public keys directly and do not use DHT topic discovery. Runtime traffic still uses HRPC, which relies on HyperDHT to establish encrypted peer connections. The machine therefore needs outbound UDP access to its configured DHT bootstrap nodes even when Kernel and the Worker share a process or host. If outbound access or network-interface inspection is blocked, startup may stop responding or fail before printing `Kernel HRPC key:`. Check: - The machine has outbound network access. - Local security tooling, containers, or sandboxes are not blocking UDP/network-interface access. - You are running the command from the repository root. - Dependencies have been installed for `backend/core` and [`backend/workers`](/reference/worker). ## Worker is HEALTHY but miners show no data `READY` and `HEALTHY` describe the Worker process, not the miners behind it: Kernel's health ping is answered by the Worker from memory and never reaches a device, so a Worker with every miner unplugged still reports both (Worker health is not device liveness). Check: - Pull telemetry for one device and read its `status` and each channel's `{ error }`; an unreachable miner surfaces there, not in the Worker state. - Confirm the miner is reachable from the Worker host on its native port ([real miners](#real-miners)). - For a command, read `verification.state` from `command.status`; only `VERIFIED` proves the device reached the requested state. ## File lock or key file errors The examples call `getKernel()` with default local paths. By default, the topic file is `~/.mdk/.dht-topic` and the kernel key file is `~/.mdk/.kernel-key`. If another Kernel, gateway, or example is already running with the same defaults, you may see file lock errors, or clients may pick up the wrong Kernel key from the shared key file. Stop stale example processes before starting another example. If you need to run several examples side by side for development, run each process with a different home directory so each Kernel gets separate local state: ```bash HOME=/tmp/mdk-avalon node avalon-dev.js ``` ## Still blocked When asking for help on [Discord](https://discord.com/invite/tetherdev) or [GitHub issues](https://github.com/tetherto/mdk/issues) collect: - The exact example command - The model and mock port - The full `stdout` and `stderr` - `node --version` and `npm --version` - Any process currently listening on the mock port # Site security blueprint (/guides/security) Backend keys identify processes, not the people using them. Supply a Gateway `authVerifier` backed by your identity system for `"auth": true` routes; other routes do not require it. Kernel checks caller keys and roles. Workers check caller keys without roles, and a Kernel role does not follow a key onto a direct Worker connection. Automatic startup can grant access to both. Review those permissions as well as user authentication, TLS, and network policy. Treat `mvp-site` and per-family snippets as boot demos, not a production template. ## TL;DR | Decision | Suggested production choice | Demo-only choice | | ---------------- | ------------------------------ | --------------------------------------------- | | Topology | Microservices over DHT, three networks (operator / backend / devices) | Single process on loopback | | Kernel admission | `auth.whitelist` with persistent Gateway and MCP keys, narrowed by role | `allowAll: true` | | Worker access | Explicit `allowedKeys`; Kernel client key only if all requests must pass through Kernel; review automatic additions | Same host does not replace the allowlist | | User identity | Your SSO or JWTs, checked in the Gateway `authVerifier` | No verifier, `noAuth()` | | Writes | Approval-gated for fleet changes; Gateway sets `authPerms` | Direct `sendCommand` with no token | | UI | HTTPS, `bearerTokenAuth` or working `gatewayRedirectAuth`, route guards | Vite proxy, `noAuth()`, HashRouter | | MCP / agent | Loopback, human approval, real `userId` | Disabled, or loopback with `local` operator | | Secrets | Secret manager | Example `admin` passwords | ## Overview Walk the steps to understand how to secure an enterprise site you assemble from MDK: UI, Gateway, Kernel, and Workers on separate hosts or containers. MDK leaves these controls to you on purpose. The SDK coordinates devices; your site owns who may talk to it. This page presents what to do, the options MDK gives you, and a suggested default, you will learn about: - [Network layout](#step-1-choose-a-network-layout) - [Kernel admission](#step-2-admit-the-gateway-to-kernel) - [Worker access](#step-3-isolate-workers-and-devices) - [Gateway identity](#step-4-put-identity-in-the-gateway) - [Write authorization](#step-5-authorize-writes) - [UI sessions](#step-6-wire-the-ui-session) - [MCP and agents](#step-7-optional-mcp-and-the-operator-agent) - [Secrets and operations](#step-8-handle-secrets-stores-and-day-two-operations) The companion pages, [security boundaries](/concepts/security-boundaries) (what each layer trusts) and the control plane (how a request travels) cover related concepts. ## Constraints the SDK does not lift Build around these. They are inherent to MDK, not optional extras. | Layer | What MDK does | What you own | | ------- | -------------------------------------------------------------------------------------- | ------------------ | | UI | Token attachment if you pass an `AuthProvider`; no route guard is applied for you (`RequireAuth` in the devkit is opt-in) | Session, HTTPS, `apiBaseUrl`, hiding write controls | | Gateway | HTTP router for apps; runs no app code. `"auth": true` routes answer `401` without an `authVerifier`; Host allowlist (loopback), rate limit, Helmet headers, body limit on by default. | `authVerifier` backed by your identity layer, `allowedHosts` | | App runtime | Runs one app's controllers in its own process. Accepts HRPC callers in `allowedKeys` / `allowedKeyFiles`; no admitted keys means no access. Uses a separate Kernel caller key, granted full access by automatic directory admission. Manifest `permissions` are not enforced. | Explicit incoming callers, Kernel roles for the app's own key, and reviewing the app before installing it | | Kernel | Encrypted HRPC. Fail-closed caller allowlist (`auth.whitelist`; empty admits nobody) with `read` / `write` / `admin` roles. Requester and non-admin voter are the transport key. `authPerms` only on approval-gated writes, taken from the caller. | Allowlist entries and roles, mapping verified users to `authPerms` / `voter` on the Gateway | | Workers | Noise transport and key-addressed HRPC. Fail-closed `allowedKeys`; every admitted key can send supported requests, including commands. No caller roles or human actor in handler context. | Choose direct callers explicitly, review automatic admission, isolate devices, validate payloads, and audit upstream | | MCP / agent | `POST /mcp` on `127.0.0.1`, no user auth by default (optional bearer `token`). Agent app binds to `local` without an identity layer. | Bind policy, human approval for writes, real `userId` | | Devices / pools | Vendor protocol APIs and authentication | Device-network isolation | For operator-facing access, use the Gateway or the standalone MCP server. Keep backend private keys and the Node.js client SDK out of the browser. Backend scripts can call Kernel or a Worker directly through the [client API](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md#createworkerclientrpckey-hrpcopts--client); the callee's allowlist determines whether the call is admitted. ## Blueprint ### Step 1: Choose a network layout Pick a deployment topology before you write auth code. Isolation is the first control; identity sits on top of it. #### Options | Option | When to pick it | Trade-off | | ---------------------- | -------------------------------------- | -------------------------------------------------------- | | Single process | Local demos, tests, smallest footprint | One heap. A compromise of the process is the whole site. | | Local (one OS process per service, shared directory) | One machine, production-like restarts, or a step toward microservices | Stronger isolation than a single heap. Kernel and Workers still share the host. No discovery topic; HRPC still runs over HyperDHT. Shared directories participate in admission. | | Microservices over DHT | Enterprise sites: separate hosts or containers, resource limits per service, Workers away from Kernel | Peers that know the discovery topic can find Kernel and Workers. Key distribution and a Kernel allowlist are mandatory. | Suggested default: microservices (one process or container per service, discovery over DHT), with three networks you define: - Operator: UI origin and Gateway HTTP, behind TLS and a reverse proxy you control - Backend: Kernel and Workers on separate hosts, no inbound path from browsers or agents - Devices: miner, container, meter, and pool APIs, reachable only from the Workers that own them Pass the Kernel public key to a remote Gateway as `kernelKey`. Treat the DHT discovery topic as deployment configuration, not a credential: generate a site-specific topic, distribute it out of band, and do not reuse example or well-known values. Pair this shape with explicit [Kernel caller admission](#step-2-admit-the-gateway-to-kernel) and pinned Worker keys for topic discovery. Knowing a topic or a listener key does not grant access: unlisted callers are rejected unless `allowAll` is enabled. Do not pass `@tetherto/mdk-client` or Kernel keys into the browser. HTTP to the Gateway is the only operator path. ### Step 2: Admit the Gateway to Kernel [`auth.whitelist`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#caller-admission-and-roles) defaults to `[]`, which admits **nobody** before startup helpers add keys. Kernel then checks each admitted caller's role on every request; the [Kernel role reference](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#caller-admission-and-roles) lists the actions each of `read`, `write` and `admin` permits. A plain key or `kernel.allowKey(key)` without roles grants `admin`. #### Options | Option | When to pick it | Trade-off | | --------------- | ---------------------- | ------------------------------------------------------------------------------------------------ | | `allowAll: true` | Throwaway local experiments only | Anyone with the Kernel public key can read telemetry and dispatch commands, skipping the Gateway. Logs a warning. | | Allowlist the Gateway DHT public key | Every real site | The Gateway store holds that key's seed: guard it like a credential | | Allowlist Gateway plus extra backend clients | A second `mdk-client` service you trust (batch jobs, a second Gateway, the standalone MCP server) | A plain key is a full (`admin`) Kernel principal; use `{ key, roles: ['read'] }` or `['write']` to narrow it. Review it like a production credential. | Suggested default: admit only the persistent backend caller keys the site needs, with explicit roles. Disable automatic directory admission when enforcing that list. Gateway clients, app runtimes, and MCP clients may each use separate keys. Start a programmatic host with explicit admission and directory-based admission disabled: ```js const kernel = await getKernel({ trustedKeysDir: false, hrpc: { whitelist: [{ key: '', roles: ['read'] }] } }) ``` Choose roles per backend caller, widen a caller to `write` only when it needs to change devices. Gateway clients use the persistent `status.rpcClientKey` from the Gateway handle or status file. An app runtime uses its own Kernel caller key, so admitting a Gateway key does not grant an app runtime access. Treat each store containing identity seeds as a private key: copying it can allow impersonation. #### Automatic admission during startup Inspect the [startup admission helpers](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md#trust-and-admission) before relying on a restricted allowlist. The trusted-keys directory stores public keys, but publishing a key there is an authorization action for the processes watching it. | Startup path | Kernel caller grants | Worker caller grants | | ------------ | -------------------- | -------------------- | | `getKernel()` with directory admission enabled | Admits every valid published caller key except its own client key with `admin`; this can replace an explicit narrower role | This function does not configure Worker allowlists | | `startGateway({ kernel })` | Calls `kernel.allowKey(gatewayKey)` with full access, even if directory publishing is disabled | An attached `trustKernel()` helper mirrors write-capable grants | | CLI Worker with a live Kernel, or a host using `trustKernel(runtime, kernel)` | Worker trust does not alter Kernel roles | Admits Kernel's client key and Kernel callers holding `write` or `admin`, now and on later grants | | CLI Worker in a separate process using `trustKeysFromDir()` | CLI Kernel startup uses the directory admission described here | Admits every valid published key without roles, including a key restricted to `read` on Kernel | | Supervised Worker through `worker.js` | Does not start Kernel | Combines `MDK_WORKER_ALLOWED_KEYS` with default trusted-directory keys and follows later directory additions | `getKernel()` uses `/.trusted-keys` by default. The CLI supplies its project-local directory; supervised Workers use `DEFAULT_TRUSTED_KEYS_DIR` (`~/.mdk/.trusted-keys`). Grant directory write access only to processes you trust to grant backend access. Filesystem protections restrict who can publish a key; they do not assign that key a narrower role. For explicit Kernel roles, use `trustedKeysDir: false` on `getKernel()`. To avoid the additional in-process grant, start the Gateway with `kernelKey` rather than the live `kernel` handle, disable its directory publishing with `trustedKeysDir: false`, and admit its caller key explicitly. Passing `trustedKeysDir: false` only to `startGateway({ kernel })` does not prevent its full-access grant. Manage MCP and app runtime keys explicitly as well. The standalone MCP server (`mdk run mcp`) is a separate Kernel caller with its own persistent key. `mdk run` publishes it to the trusted-keys dir; on a multi-host site add it to `auth.whitelist` alongside the Gateway, or its tools can't reach Kernel. Each app runtime is a Kernel caller too, with its own persistent key: `mdk run` publishes it as `app-.pub` in the trusted-keys dir, next to `kernel.pub`, `gateway.pub`, `mcp.pub` and `cli.pub`. The directory watcher admits valid caller keys with full access, excluding Kernel's own client key. An app's `permissions` in `mdk-app.yaml` are not enforced by Kernel yet, so review an app before you add it to a stack. #### Worker discovery admission The list of Workers a Kernel may discover is separate from the list of clients allowed to call Kernel. For DHT-topic discovery, only Worker RPC keys on `discovery.workerKeys` (or added with `kernel.allowWorkerKey()`) register, and an empty list admits nobody. `discovery.allowAnyWorker: true` admits every peer that knows the topic; like `allowAll`, it is for throwaway local experiments only and logs a warning. With it, a peer with the topic could register as a Worker and receive the commands, including credentials, for its device family. Local shared-directory discovery and explicit `kernel.registerWorker()` skip that topic allowlist. Control who can publish Worker keys or call registration; adding `discovery.workerKeys` **does not** restrict those paths. On separate hosts, pass Kernel's public key to the Gateway as `kernelKey`. Do not rely on the well-known key file (`~/.mdk/.kernel-key`) across machines. Where that file still exists on the Kernel host, it holds the public key, written world-readable (`0644`) inside a directory you own; it is not deleted on shutdown. Do not expose Kernel's HRPC listener off the backend network. ### Step 3: Isolate Workers and devices [`WorkerRuntime`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/README.md) rejects unlisted caller keys at connection time and checks the key again on every request. With `allowAll` disabled, it refuses to start with an empty `allowedKeys` list. This is a key-based firewall, not a list of allowed server addresses. An admitted key can send any request the Worker supports, including `command.request`; the list has no read-only role. Choose which request paths the site allows: | Path | Checks that apply | Consequence | | ---- | ----------------- | ----------- | | Client to Kernel to Worker | Kernel caller key and action role, [Kernel command handling](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#commanddispatcher), then the Worker's allowlist for Kernel's client key | Kernel mediates the request; approval runs only for the approval-gated action path | | Client directly to Worker | Worker's caller allowlist, request validation, and Worker handlers | Kernel roles, command lifecycle records, and its approval flow do not run | The [direct Worker client](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md#createworkerclientrpckey-hrpcopts--client), `sendWorkerCommand()` and `pullWorkerTelemetry()` use the second path. Resolving a Worker's public key through Kernel does not make the following Worker call Kernel-mediated. The caller still needs a key admitted by that Worker. This is separately granted access, not a way around the Worker's firewall. For example, a monitoring script admitted to Kernel as `read` cannot send a command through Kernel. If its key is also on a Worker's allowlist, the same script can send supported commands directly to that Worker. To keep it read-only, restrict both paths. For a site that requires all device requests to pass through Kernel, set each Worker's `allowedKeys` to only `kernel.getClientPublicKey()`, not Kernel's listener key from `getPublicKey()`. Do not add the Gateway, MCP, app runtime, or script key to that list. If the site deliberately needs direct provisioning or Worker queries, admit the required caller explicitly and treat it as able to issue supported commands too. An initial `allowedKeys` list is not an exclusive policy when a trust helper adds more keys afterward. The CLI installs `trustKernel()` / `trustKeysFromDir()` automatically, and `worker.js` adds trusted-directory keys even when `MDK_WORKER_ALLOWED_KEYS` is set (additive, not an override). Kernel-only access needs a programmatic host with explicit Worker boot options and neither helper. Keep the Worker firewall enabled and isolate the network as an additional control. [Security boundaries](/concepts/security-boundaries) explain the distinction between endpoint identity, key admission, and user authentication. #### Options | Option | When to pick it | Trade-off | | ---------------------------------------------------- | --------------- | --------------------------------------------------------------------------- | | Workers only on the Kernel network, keys unpublished | Every site | Operational discipline: no Worker port or DHT topic on the operator network | | One Worker process per trust zone | Different device families or vendors must not share credentials | More processes to supervise | | Device APIs on a third network (CGMiner `4028`, miner HTTP, container APIs) | Real hardware | Required when the vendor protocol has weak or no auth ([Avalon](/guides/miners/run-avalon-worker) / CGMiner have none; [Antminer](/guides/miners/run-antminer-worker) uses digest auth; [Whatsminer](/guides/miners/run-whatsminer-worker) uses a device password) | Suggested default: persist each Worker's `storeDir` so its key is stable, treat that key as a machine identity, and register the Worker with Kernel only after the device network is isolated. On DHT, keep Worker public keys and the discovery topic off the operator network and out of git. Inject device passwords and pool API keys from a secret manager or protected environment. Never put secrets in `mdk-contract.json`, git, or a committed seed file. Validate command payloads again in the handler (types, ranges, allowed targets). Kernel checks the command name against the contract; it does not know your hardware's unsafe parameter combinations. Redact credentials and vendor responses in handler errors and logs. For CGMiner-style devices, network isolation is the control: changing the Worker `password` field does not add a wire handshake the protocol lacks. ### Step 4: Put identity in the Gateway The Gateway is the only supported user-auth seam. Pass `startGateway()` an `authVerifier(request, route)` backed by your identity layer: routes a manifest marks `"auth": true` answer `401` unless it returns an identity, which controllers read as `req._info`. Routes without `"auth": true` are served to any caller. Paths under `/auth/metrics/*` are historical names, not a login gate. #### Options | Option | When to pick it | Trade-off | | --------------------------------------- | --------------- | ------------------------------------- | | Your SSO (OIDC / SAML / existing IdP), validated in the `authVerifier` | Production sites with an identity provider | You write the verifier and mark routes `"auth": true`: this is the supported path | | Gateway-issued JWT after an OAuth redirect you implement (`/oauth/...` → `?authToken=`, plus `POST /auth/token` and `/auth/userinfo`) | Browser UI using `gatewayRedirectAuth({ oauthBaseUrl })` | You still write those routes | | Bearer tokens only (`Authorization: Bearer`), no browser redirect | Service accounts, scripts, or a UI that already has a token | Pair with `bearerTokenAuth()` in the UI | | No identity (`noAuth()`, no `authVerifier`) | Laptop demos bound to loopback | `"auth": true` routes stay closed; every other route is open to anyone who can reach the port | Suggested default: one `authVerifier` for the whole Gateway, and `"auth": true` on every route that reads or changes the fleet. Map verified roles onto Kernel `authPerms` (`miner:w`, `container:w`, and any others you define). Never accept `authPerms` or `voter` from the HTTP client. Return `false` for a missing or bad token (`401`) and throw a `403` `ERR_*` error for a missing permission. ```js // The verifier does the check; the Gateway apps guide owns the full example. authVerifier: async (request, route) => { const identity = await verify(request.headers.authorization) if (!identity) return false if (!(route.permissions || []).every(p => identity.perms.includes(p))) { throw Object.assign(new Error('ERR_AUTH_PERM_DENIED'), { statusCode: 403 }) } return identity } ``` The Gateway sanitizes error bodies: only `ERR_*` codes and Fastify's own 4xx errors reach the client verbatim; the [Gateway security model](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md#security-model) details the shape and what gets logged instead. The [Gateway apps guide](/guides/gateway/apps#auth-and-permissions) owns this pattern. Also: - Bind HTTP to the proxy network, not a public interface without that proxy - List the names clients use in `allowedHosts`; only loopback on the bound port passes the Host check by default - Tune `rateLimit` for writes. `?overwriteCache=true` is honored only for an identity whose `perms` holds `cache:overwrite` - `trustProxy` is off by default. Turn it on only behind a proxy whose hop count you trust, or client IPs (and so rate limits) can be spoofed - Log actor, route, target, command, outcome, and a correlation id for every write. Redact secrets. Worker handlers cannot see actor identity, so this audit stays in the Gateway ### Step 5: Authorize writes Kernel distinguishes a command sent to Kernel (`command.request`) from an approval-gated action (`action.push` and votes). Both require a Kernel caller role of `write` or `admin`. A command sent directly to a Worker is a third path with only the Worker checks described in Step 3. Choose the path per action and enforce that choice at every entry point you admit. Kernel's role check authorizes the backend process, not the person behind an HTTP request. For commands sent to Kernel, the Gateway or app controller must authorize that person. Approval-gated writes also check `authPerms`, but the admitted caller supplies that array; derive it from verified identity. Non-admin callers vote as their transport key; an `admin` caller may forward a user's `voter` identity. Grant that delegation only to a process trusted to authenticate those users. #### Options | Option | When to pick it | Trade-off | | ------------------------------------- | --------------- | --------------------------------------- | | Command to Kernel (`command.request`) after a controller permission check | Low-impact, operator-in-front actions (LED, a single-device reboot you already authorized) | Requires a write-capable backend key; no approval vote or human-identity check runs in Kernel | | Approval-gated write ([`action.push`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#actionmanager), then votes) | Fleet-changing actions: power mode, pool assignment, container PDU, bulk reboot | Slower. Still requires the Gateway to set `authPerms` and `voter` from the verified token. The [write-actions guide](/guides/gateway/write-actions) covers the HTTP side. | | Agent-proposed write with a human approval pause | Operator agent (`approvalTimeoutMs` on `@tetherto/mdk-app-agent`) | The model cannot execute a write until someone approves. Without identity, every request is the same `local` operator. | Suggested default: approval-gated writes for anything that changes hash, power, or network membership; direct commands only behind a controller that already checked a token. Do not expose a raw `sendCommand` route to the UI without that check. ### Step 6: Wire the UI session [``](https://github.com/tetherto/mdk/blob/main/ui/packages/react-adapter/README.md#authentication) is how the browser attaches a credential. The toolkit does not guard routes or deny writes on its own. #### Options | Option | When to pick it | Trade-off | | --------------------------------------- | --------------- | ------------------------------------- | | `noAuth()` | Local dev against a Gateway without an auth plugin, demos | No token. A finished-looking dashboard still sends unauthenticated calls. | | `bearerTokenAuth()` | You already issue JWTs from the Gateway identity plugin | Session ends on `401`. You own login UI. | | `gatewayRedirectAuth({ oauthBaseUrl })` | OAuth redirect, `?authToken=` capture, refresh against `POST /auth/token` | Needs *your* token routes. Default `MdkProvider` auth without `oauthBaseUrl` cannot sign in. | | Custom `AuthProvider` | A session model the three presets do not cover | You implement `getToken`, refresh, and sign-out | Suggested default for a production dashboard: - `` or `gatewayRedirectAuth({ oauthBaseUrl })` after those routes exist - HTTPS, a real `apiBaseUrl`, no Vite dev proxy - One fetch client so pages cannot skip `Authorization` - Route guards on any view that can mutate the fleet (the shell template uses ``; copy that idea, not `noAuth()`) - Hide write controls unless the token carries the matching permission. Hiding is not authorization; the Gateway still denies - Scrub `?authToken=` from the URL after capture. Tokens in query strings leak through access logs and Referer - Static bundle behind the same proxy that terminates TLS. Do not run `vite dev` as the production UI The mvp-site example UI has no sign-in, no session, and a hardcoded profile. Do not copy that into a reachable host. ### Step 7: (Optional) MCP and the operator agent MCP is optional. When you enable it, it has the same authority as the process that hosts it. The standalone `@tetherto/mdk-mcp` process binds `127.0.0.1` and answers `POST /mcp` with no user auth by default; an optional bearer `token` gates every call. The Gateway hosts no MCP itself. #### Options | Option | When to pick it | Trade-off | | ---------------- | --------------------------- | ------------------------------------------------- | | No MCP, no agent | Dashboards and scripts only | Smallest surface | | MCP on loopback, operator agent on the Gateway | In-process chat for people who already passed Gateway identity | Keep `approvalTimeoutMs` on. Pass a real `userId` into `createSession` / `resumeSession`. A session id in a URL must not read another operator's chat | | MCP or agent published past localhost | An agent runtime on another host | MCP does not identify individual users; its optional bearer token grants shared access. Put mutual TLS or your SSO proxy in front, and authorize inside the reused HTTP handlers (an app's routes served as MCP tools run the same handlers under one server-wide `identity`, with no per-user check; `auth: true` tools stay hidden unless that identity is configured) | Suggested default: leave MCP on `127.0.0.1`. Do not publish it with compose `ports:`, an SSH tunnel, or a reverse proxy unless that front door is authenticated. Treat the model provider (`qvac` or an OpenAI-compatible URL) as sensitive infrastructure: prompts can include site topology and, if you are careless, secrets. ### Step 8: Handle secrets, stores, and day-two operations #### Options for secrets | Option | When to pick it | Trade-off | | -------------------- | --------------- | -------------------------------------------------------- | | Secret manager (Vault, cloud SM, Kubernetes secrets) into the host process | Production | Extra moving part. This is the suggested default. | | Protected environment on a locked-down host | Small single-host sites | Rotation and audit are your scripts | | Values in git, `mdk-contract.json`, example `site.deploy.json`, or the UI bundle | Never | Credentials leak with the repo or the browser | #### Options for process lifecycle | Option | When to pick it | Trade-off | | ----------------------------------------- | --------------- | ----------------------------------- | | `SIGINT` / supervisor stop so WAL and cleanup run | Always | A `SIGKILL` can leave `.mdk-data` / `storeDir` unusable. The next boot may look healthy with zero Workers. | | Health check HTTP only (an HTTP route the Gateway serves) | Insufficient | The Gateway keeps serving HTTP while the Kernel is unreachable; Kernel-backed routes fail with `ERR_MDK_CLIENT_UNAVAILABLE` until it is back. | Suggested default: - Mode `0700` (or equivalent) on the Kernel db directory and Worker `storeDir`, and `0600` on seed and key files. Encrypt at rest if the host requires it. - Back up those directories as operational data, with the same access control as the live files - Rotate keys when access changes, and update every callee that admits them, including direct Worker grants - Practice recovery from a wiped data directory before an incident - Watch Gateway-to-Kernel errors, not only the HTTP port - Run Node.js >=24. Pin lockfiles. Prefer non-root containers with a read-only root filesystem - Stay on the latest `main` or latest tag you have reviewed. `0.y.z` is initial development; older tags may not receive security fixes. Report product issues through the [security policy](https://github.com/tetherto/mdk/blob/main/SECURITY.md), not public GitHub issues #### Revoke access on every admitted path Trusted-directory watchers add keys; deleting a `.pub` file does not revoke its grants in running processes. Use `kernel.revokeKey(key)` and each affected `runtime.revokeKey(key)`, and remove the publication and startup configuration that would grant it again. With `allowAll` enabled, removing an allowlist entry does not deny that caller; disable `allowAll` as well. `trustKernel()` mirrors explicit Kernel revocations to attached Workers, but changing a Kernel role from `write` to `read` does not remove an existing Worker grant. A directory watcher does not mirror Kernel role changes or revocations. Check the live Kernel roles and Worker allowlists, not just files on disk, and verify that denied calls fail after revocation and after restart. ## Next steps - Read the [security boundaries](/concepts/security-boundaries): what each layer trusts, and what it does not - Follow the control plane: read path, direct command, and approval-gated write - Add identity in [app controllers](/guides/gateway/apps#auth-and-permissions): the only supported user-auth seam - Restrict Kernel callers with the [HRPC allowlist](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#caller-admission-and-roles): transport admission for the Gateway - Compare deployment topologies: this blueprint assumes microservices; local and single-process are smaller footprints - Apply the blueprint as values with the [hosted-site reference configuration](/guides/deployment/hosted-site): two profiles and one settings table - Report product vulnerabilities through the [security policy](https://github.com/tetherto/mdk/blob/main/SECURITY.md): private advisory, not a public issue # UI guides (/guides/ui) } title="Install and wire the React packages" href="/guides/ui/install" description="Install the three MDK React packages, wrap your app in MdkProvider, and wire headless stores" /> } title="React" href="/guides/ui/react" description="Compose reporting layouts using MDK React foundation components" /> } title="Core (headless)" href="/guides/ui/use-ui-foundation-headlessly" description="Use MDK UI Foundation headlessly, without the React adapter" /> } title="Agent skills" href="/guides/ui/agent-skills" description="Install the MDK Developer Skill suite so your coding agent knows MDK conventions and component props" /> # Agent skills (/guides/ui/agent-skills) ## Overview The **MDK Developer Skill suite** (`@tetherto/mdk-skill`) is how an AI coding agent learns to build with MDK. It ships procedural context in the universal Agent Skills format (`SKILL.md`), so any skills-aware agent (Cursor, Claude Code, and others) becomes fluent in MDK conventions as soon as the suite is installed. Nothing here calls a network or a model. Each skill is a file the agent reads, bundled with the component registry MDK builds from its own source. Install once, then let the agent do the rest. ## Prerequisites - `mdk` on your PATH (see [Install the CLI](/guides/cli/install)) ## Install From your project root: ```bash mdk skill add # Adds both Cursor and Claude Code ```
Optional flags Use `--client` to target a named agent, or `--dir` to install into another directory, for example: - `mdk skill add --client cursor` # .cursor/skills/ - `mdk skill add --client claude` # .claude/skills/ - `mdk skill add --dir path/to/app` # target another directory The [CLI reference](https://github.com/tetherto/mdk/blob/main/packages/cli/README.md#command-surface) lists the options. `mdk onboard` also offers the install as one of its steps.
Skills land flat: one directory per skill, each with its own `SKILL.md`. Agents discover them automatically, so there is no rule file to wire by hand. Installs are copies, not links: rerun `mdk skill add` after upgrading MDK to refresh them. The rerun also removes MDK-shipped skills the new version no longer includes, so a renamed or dropped skill cannot linger and keep triggering. Skills you authored yourself in the same directory are left alone. ## What the suite covers The suite ships a router plus workflows for device workers, apps, UI pages, and stack deployment. Each skill's `description` frontmatter is its routing trigger, so you state an intent in plain language and the right skill activates; composite prompts route as an ordered chain through the router. See the [skill suite](https://github.com/tetherto/mdk/blob/main/packages/mdk-skill/README.md) for the current inventory and each skill's triggers, and the [routing prompts](https://github.com/tetherto/mdk/blob/main/packages/mdk-skill/README.md#try-it--routing-prompts) for worked examples. ## What the UI skill knows Use the [`mdk-ui-component`](https://github.com/tetherto/mdk/blob/main/packages/mdk-skill/src/skills/mdk-ui-component/SKILL.md) skill to build dashboards. Alongside its [workflow](https://github.com/tetherto/mdk/blob/main/packages/mdk-skill/src/skills/mdk-ui-component/SKILL.md#workflow) it carries: - The React Devkit's **generated component registry**, listing every [agent-ready component](/reference/ui/components) with its exact prop names and types, so an agent can't invent props that do not exist. It's generated from the devkit's source on every release and never hand-written. - The [page recipe](https://github.com/tetherto/mdk/blob/main/packages/mdk-skill/src/skills/mdk-ui-component/references/page-recipe.md): the layer-by-layer contract a page follows. A page is composition only: data comes from a Gateway route, a hook shapes the payload, the page is thin glue that calls the hook, and the visuals come from the Devkit. The [page recipe](https://github.com/tetherto/mdk/blob/main/packages/mdk-skill/src/skills/mdk-ui-component/references/page-recipe.md) spells out each layer's responsibilities. ## Verify the install Open the project in a skills-aware agent and state one of the intents above. The agent should name the skill it activated. If nothing activates, confirm the skill files landed in your project (for example under `.cursor/skills/` or `.claude/skills/`). `mdk skill add` does not edit `.gitignore`. Add the installed skill directories to yours so the suite stays per developer, not committed. Ignore the suite's directories by name rather than the whole skills folder, which may also hold your team's own committed skills: ```text .claude/skills/mdk/ .claude/skills/mdk-worker-plugin/ .claude/skills/mdk-gateway-plugin/ .claude/skills/mdk-ui-component/ .claude/skills/mdk-deployment/ .claude/skills/mdk-site-sizing/ ``` Repeat for `.cursor/skills/` if you install there. `.claude/skills/mdk*/` is a shorter alternative, but it also hides any skill of your own whose name starts with `mdk`. ## Next steps - [Build a dashboard](/tutorials/build-a-dashboard): put the skills to work on a real page - See [the end-to-end flow the skill follows](https://github.com/tetherto/mdk/blob/main/packages/mdk-skill/src/skills/mdk-ui-component/SKILL.md#workflow) - Review [each layer's responsibilities](https://github.com/tetherto/mdk/blob/main/packages/mdk-skill/src/skills/mdk-ui-component/references/page-recipe.md) - Browse the [components](/reference/ui/components) the agent composes # Install and wire the React packages (/guides/ui/install) This page walks through the minimum integration of the MDK UI toolkit into a React application. Reference and component pages link here as their shared installation prerequisite. ## Prerequisites - **Node.js** >=24 - **npm** >=11 - **React** 19+ and **react-dom** 19+ ## Install ```bash npm install \ @tetherto/mdk-react-devkit \ @tetherto/mdk-react-adapter \ @tetherto/mdk-ui-foundation ``` ```bash # Clone the MDK UI monorepo (adjust the URL to your fork if needed) git clone https://github.com/tetherto/mdk.git cd mdk/ui # Install dependencies and build packages (npm workspaces) npm install npm run build ``` Then add to your app's `package.json`: ```json { "dependencies": { "@tetherto/mdk-react-devkit": "*", "@tetherto/mdk-react-adapter": "*", "@tetherto/mdk-ui-foundation": "*" } } ``` Run `npm install` from the `mdk/ui` workspace root after your app is under `apps/` so npm links workspace packages. [Wrap your app](/guides/ui/install#wrap-your-app-in-mdkprovider) in `` from `@tetherto/mdk-react-adapter` when using connected foundation components or adapter store hooks. ## Wrap your app in MdkProvider `MdkProvider` sets up the TanStack `QueryClient` and the API base URL context. It is required for foundation hooks and components that read shared app state. ```tsx // main.tsx ReactDOM.createRoot(rootElement).render( , ) ``` `apiBaseUrl` must point at a Gateway that mounts the routes your pages read. A Gateway serves only what the apps it declares provide, so the endpoints behind these hooks come from an app you ship. [Writing an app's routes and declaring it in your stack](/guides/gateway/apps) covers that side. ## Use the adapter hooks inside React Each hook subscribes the component to the relevant Zustand store and re-renders only when the selected slice changes. ```tsx const Toolbar = () => { const { permissions } = useAuth() const { selectedDevices } = useDevices() // ... } ``` ## Or read / write stores directly outside React The vanilla stores expose `getState()` / `setState()` so utility code, side-effect handlers, and tests can interact with the same source of truth. ```tsx // Outside React (utilities, sagas, etc.) you can read/write directly: devicesStore.getState().setSelectedDevices([]) timezoneStore.getState().setTimezone('UTC') ``` ## Theme via design tokens and @layer mdk The compiled stylesheet declares `@layer base`, `mdk`, `app`, so unlayered or `@layer app` styles in your application always win against devkit component styles. MDK ships with `--mdk-color-primary: #f7931a`; override tokens in `:root` only when reskinning. ```css /* app.css — imported AFTER @tetherto/mdk-react-devkit/styles.css */ :root { --mdk-color-primary: #f7931a; --mdk-radius: 6px; } @layer app { .mdk-button--variant-primary { letter-spacing: 0.04em; } } ``` ## Next steps - Browse the [state, component, and utility hooks](/reference/ui/hooks): what each hook selects and re-renders on - Browse the [component reference](/reference/ui/components): building blocks and mining-domain components, with props and usage # React UI guides (/guides/ui/react) } title="Compose reporting layouts" href="/guides/ui/react/compose-reporting-layouts" description="Compose the timeframe week selector from the shared building blocks the reporting surfaces use" /> # Compose reporting layouts (/guides/ui/react/compose-reporting-layouts) @tetherto/mdk-react-devkit/domain The prebuilt reporting composites (`Cost`, `Ebitda`, `EnergyBalance`, `HashBalance`, `Hashrate`) and their per-composite guides were removed from the devkit in 0.12. The shared timeframe building blocks below are what remains of that surface. The building blocks below receive pre-shaped data as props and do no fetching, so you wire your own data layer (RTK Query, TanStack, fixtures) around them. ## Shared building blocks These power the week selector inside [`TimeframeControls`](/reference/ui/components/filters#timeframecontrols), shared across the financial reporting surfaces. | Component | Description | | --- | --- | | [`TimeframeWeekFlatContent`](/guides/ui/react/compose-reporting-layouts/#timeframeweekflatcontent) | Flat week-list for the week selector | | [`TimeframeWeekTreeContent`](/guides/ui/react/compose-reporting-layouts/#timeframeweektreecontent) | Year-month-week tree for the week selector | ### `TimeframeWeekFlatContent` Flat list of selectable week items for the TimeframeControls week selector. Shared building block of the reporting timeframe controls. ```tsx ``` Renders inside the week selector of [`TimeframeControls`](/reference/ui/components/filters#timeframecontrols). ### `TimeframeWeekTreeContent` Hierarchical year to month to week tree for the TimeframeControls week selector. Shared building block of the reporting timeframe controls. ```tsx ``` Renders inside the week selector of [`TimeframeControls`](/reference/ui/components/filters#timeframecontrols). # Use UI Foundation headlessly (/guides/ui/use-ui-foundation-headlessly) @tetherto/mdk-ui-foundation [`@tetherto/mdk-ui-foundation`](/reference/ui) is the framework-agnostic headless layer of the MDK App Toolkit. This how-to walks through installing it on its own and driving its Zustand stores from a non-React runtime — a Node script, a Vue or Svelte adapter you're authoring, a CLI tool, or a test helper. ## When to reach for this Use headless UI Foundation when: - You're authoring a framework adapter (Vue, Svelte, Web Components) and need raw access to the Zustand stores. - You're building a Node CLI or backend service that has to read MDK telemetry and act on it. - You're writing test helpers or fixtures that need to seed and inspect store state without a React renderer. - You need to subscribe to store changes from non-UI code — logging, websocket bridges, metrics. For a React app, the [React adapter](/guides/ui/install) wraps UI Foundation with `` and adapter hooks. Use that path instead so most React code never touches `@tetherto/mdk-ui-foundation` directly. ## Install `@tetherto/mdk-ui-foundation` has no peer dependencies on React or any UI framework. ```bash npm install @tetherto/mdk-ui-foundation ``` ## Subpath imports Pull only the pieces you need from the relevant subpath. Subpath imports give tree-shakers a smaller surface than the top-level barrel: ```ts ``` These are the supported subpath entries — `/store`, `/query`, and `/types`. ## Create a `QueryClient` `createMdkQueryClient` returns a TanStack Query Core client wired to your Gateway. Pass an explicit `apiBaseUrl`, or let the factory resolve one from environment variables: ```ts const queryClient = createMdkQueryClient({ apiBaseUrl: 'https://app-node.example.com', }) ``` Without an explicit `apiBaseUrl`, the factory checks `VITE_MDK_API_URL` then `MDK_API_URL` before falling back to `http://localhost:3000`. ### Bring your own backend `createMdkQueryClient` also accepts `fetcher` and `endpoints`, the seam for pointing the query layer at a backend other than the mining Gateway: - `fetcher` swaps the transport: pass a `Fetcher` that talks to a custom auth scheme, a different protocol, or serves fixtures from memory for a server-less demo. - `endpoints` remaps the `:name` path templates the factories request, pointing the same factories and adapter hooks at a different API's URL space. ```ts const queryClient = createMdkQueryClient({ apiBaseUrl, endpoints: MY_ENDPOINTS, fetcher: myFetcher, }) ``` Both are stashed on the client's query and mutation `meta` and read back by every query and mutation factory, so the same factories and adapter hooks work unchanged regardless of which backend is behind them. > [!TIP] > The catalog app's `bring-your-own-backend` example takes this further: it skips the query layer entirely and drives the same devkit components from plain TanStack `useQuery` against a foreign API shape, useful when a backend doesn't fit the `fetcher`/`endpoints` seam at all. ## Read store state Each store is a Zustand vanilla singleton. `getState()` returns the current snapshot: ```ts const { token, permissions } = authStore.getState() console.log('current token', token) ``` ## Write store state `setState()` accepts either a partial object or a function that receives the previous state: ```ts devicesStore.setState({ selectedDeviceId: 'wm-002' }) devicesStore.setState((prev) => ({ devices: [...prev.devices, newDevice], })) ``` ## Subscribe to changes `subscribe()` runs a callback on every state change and returns an unsubscribe function: ```ts const unsubscribe = notificationStore.subscribe((state) => { console.log('unread notifications:', state.count) }) unsubscribe() ``` ## A complete Node example A small Node script that authenticates against the Gateway, fetches the device list once, and then tails unread notification count changes: ```ts authStore, devicesStore, notificationStore, } from '@tetherto/mdk-ui-foundation/store' async function main() { const queryClient = createMdkQueryClient({ apiBaseUrl: process.env.MDK_API_URL ?? 'http://localhost:3000', }) authStore.setState({ token: process.env.MDK_TOKEN ?? '' }) const devices = await queryClient.fetchQuery({ queryKey: ['devices', 'list'], queryFn: async () => { const res = await fetch(`${process.env.MDK_API_URL}/api/devices`, { headers: { Authorization: `Bearer ${authStore.getState().token}` }, }) return res.json() }, }) devicesStore.setState({ devices }) console.log(`Found ${devices.length} devices`) const unsubscribe = notificationStore.subscribe((state) => { console.log(`unread notifications: ${state.count}`) }) process.on('SIGINT', () => { unsubscribe() process.exit(0) }) } main().catch((err) => { console.error(err) process.exit(1) }) ``` Run it with: ```bash MDK_TOKEN=ey... MDK_API_URL=https://app-node.example.com node script.ts ``` For the prebuilt query and mutation factories (`authQuery`, `devicesQuery`, `deviceQuery`, `telemetryQuery`), check the [UI reference](/reference/ui). ## Next steps - [UI reference](/reference/ui): full store list, query helpers, and the `createMdkQueryClient` resolution order. - [What's an app?](/concepts/whats-an-app): where the UI devkit fits into an MDK app's anatomy. - [React adapter](/guides/ui/install): if you decide to layer React on top. # Build a third-party Worker (/guides/workers/build-a-worker) ## TL;DR A Worker plugin package is: - [`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) at the package root - A file at the path each contract entry's `handler` field names ## Overview This guide is for partners who want to integrate their own hardware, firmware, or data feed with MDK by shipping a Worker plugin package from their own public or private repository — no fork of this monorepo and no PR into `tetherto/mdk` required. It walks through building a Worker from scratch, end to end: - The device client - The [`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) - The [handlers](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js) - The mock - The tests Hosting the finished package (pointing [`WorkerRuntimeV2`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js) at it and registering with a live Kernel) is a separate concern, covered in [Test a Worker with MDK](/guides/workers/test-a-worker), which hosts the package this guide builds. A Worker plugin package is **loaded from its own directory**: [`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) declares each handler by path, `src/` holds the handler modules, and the host points `WorkerRuntimeV2` at the directory. The package ships only its contract and handler files; `WorkerRuntimeV2` loads them directly rather than requiring an exported module. Handlers are plain `(params)` functions that read their device from the ambient `@tetherto/mdk-worker/device` module. See [`worker-runtime-v2.js`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js) for the full shape of that module. The same shape ships as the template `mdk create worker` scaffolds ([`packages/cli/templates/worker/`](https://github.com/tetherto/mdk/blob/main/packages/cli/templates/worker/README.md)). It needs **zero** of this monorepo's optional worker-infra services (provisioning stores, alert templates, stats aggregation), just [`WorkerRuntimeV2`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js) and the directory-loaded Worker plugin shape. This guide adds production-oriented validation, recovery, audit, and tests that the deliberately small template does not include. This guide uses **partner integration** for the complete integration, **Worker plugin package** for the static contract and handlers, **host process** for the Node.js process that owns [`WorkerRuntimeV2`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js), and **device ID** for a runtime device identity. ## What you get ```text your-worker-repo/ package.json mdk-contract.json # the engineering + AI-context contract src/ client.js # plain I/O against your vendor's native API, no MDK concepts telemetry/*.js # one handler per telemetry field commands/*.js # one handler per command mock/ server.js # a standalone fake of the vendor's device API tests/ unit/handlers.test.js # drives loadContract() + createInstance() against the mock # (no WorkerRuntimeV2 involved) ``` This tree is the `mdk create worker` template's layout plus a `tests/` directory, with the vendor name as the placeholder `vendor`. Its tests run with **zero** dependency on `WorkerRuntimeV2`. The `src/telemetry/`, `src/commands/`, and `client.js` naming above is a convention, **not** a requirement. [`WorkerRuntimeV2`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js) only requires two things: [`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) at the package root, and a real file at whatever path each contract entry's `handler` field names. Following the same layout as the template just keeps your package legible to anyone who has read another MDK Worker. ## Prerequisites - Node.js `>=24` (all MDK core packages declare this `engines` constraint) - npm 11 [(< 12)](/reference/environment) - A device or firmware API you can talk to from Node — HTTP, TCP, Modbus, MQTT, serial, whatever your hardware speaks - Comfort with plain async JS — no MDK-specific framework knowledge is required to write the device client - A basic understanding of [how MDK works](/concepts/architecture), the [Worker install pattern](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/install-pattern.md), and the [Worker discovery model](https://github.com/tetherto/mdk/blob/main/backend/workers/README.md) ### Scaffold the package Create your own repo (or a directory inside your existing one) with a `package.json`. Pick your own npm scope (as an external Worker provider, you publish under your own domain (not `@tetherto`)): ```json { "name": "@your-org/mdk-worker-vendor", "version": "0.1.0", "description": "MDK Worker plugin for Vendor firmware v1 devices", "license": "Apache-2.0", "engines": { "node": ">=24" }, "type": "commonjs", "scripts": { "lint": "standard", "test": "npm run lint && npm run test:unit", "test:unit": "NODE_ENV=test brittle tests/unit/*.test.js" }, "dependencies": { "debug": "^4.4.1" }, "devDependencies": { "brittle": "^3.16.0", "standard": "^17.1.2" } } ``` Handler files are loaded with `require()`, so set `"type": "commonjs"` or use `.cjs` files. An ESM-only package (`"type": "module"` with `.js` handlers) is not a supported handler-loading path today. `brittle` and `standard` are the repository's test and lint tools; substitute your own tooling if you prefer. Your own contract-level tests (`loadContract`, `createInstance`, see Step 7) need `@tetherto/mdk-worker` too. Install it from npm, the same way [Test a Worker with MDK](/guides/workers/test-a-worker)'s [Install MDK step](/guides/workers/test-a-worker) does: ```bash npm install @tetherto/mdk-worker ``` The host process that constructs `WorkerRuntimeV2` and brings its transport dependencies (`@hyperswarm/rpc`, `hyperswarm`, `hyperdht`) is a separate package, not this one. The ambient `@tetherto/mdk-worker/device` import your handler files use (Step 2 onward) does not depend on that install: `WorkerRuntimeV2` intercepts that exact string before Node resolves it, so it works whether or not `@tetherto/mdk-worker` is present in `node_modules`. The install is what the plain `require('@tetherto/mdk-worker')` calls in your own test and verification scripts resolve against. ### Write the device client This is the part that's actually yours: plain I/O against your vendor's native API. No MDK concepts, no base classes. `WorkerRuntimeV2` loads every file your handlers require into a private module registry per device (see [`worker-runtime-v2.js`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js)), so a client module that binds directly to its device at load time already gets one instance per device, with no factory function and no explicit construction. It reads its device's connection details from the ambient `@tetherto/mdk-worker/device` module: `{ id, opts, env, config, logger }`, where `opts` is this device's own connection config and `env` is the plugin-wide block the host passed when constructing the runtime. [`createModuleContext`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/module-context.js) is the primitive behind that registry: it gives each plugin instance its own `require` cache, so module-level state — a client constructed at load time, say — belongs to that one instance alone. `WorkerRuntimeV2`, the Gateway, and the MCP server each build one per plugin. `WorkerRuntime` v1 has no notion of per-device isolation and does not use it, which is why a v1 plugin needs an explicit `connect()`. `src/client.js`, a trimmed version of the template's own `client.js`: ```js 'use strict' const { opts, env, logger } = require('@tetherto/mdk-worker/device') logger('config received: opts=%o', opts) const TIMEOUT_MS = opts.timeoutMs || 5000 const base = `http://${opts.host || '127.0.0.1'}:${opts.port}` const auth = env.DEVICE_TOKEN ? { authorization: `Bearer ${env.DEVICE_TOKEN}` } : {} const call = async (path, callOpts = {}) => { try { const res = await fetch(base + path, { ...callOpts, headers: { ...auth, ...callOpts.headers }, signal: callOpts.signal || AbortSignal.timeout(TIMEOUT_MS) }) const body = await res.json() if (!res.ok || body.ok === false) { throw new Error(body.error || `ERR_DEVICE_CALL_FAILED: ${res.status}`) } return body } catch (err) { if (err.name === 'TimeoutError') { throw new Error(`ERR_DEVICE_TIMEOUT: ${path}`) } throw err } } module.exports = { getSummary: () => call('/api/v1/summary'), reboot: () => call('/api/v1/reboot', { method: 'POST' }), setPowerMode: (mode) => call('/api/v1/power-mode', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode }) }) } ``` Whatever your device speaks — HTTP + digest auth, Modbus TCP, MQTT, a binary serial protocol — it lives entirely in this one file. Everything downstream only ever calls the methods this returns. Use a finite timeout for every device operation and propagate cancellation when the underlying client supports it. Retry idempotent telemetry reads only when the device protocol makes that safe, with bounded exponential backoff and structured logging owned by the host process. Do **not** automatically retry physical commands: a timeout can mean the command succeeded but its response was lost, so retrying can duplicate the operation. This module loads once, when `WorkerRuntimeV2` opens this device's context, and stays loaded for the life of the process; nothing probes the device up front. An unreachable device does not fail at load time; the failure surfaces from the first handler call that actually reaches the network (see Step 4). ### Declare the contract `mdk-contract.json` is the static source of truth for what telemetry your Worker reports, what commands it accepts, and the semantic context an AI agent or human operator needs to use it safely. The [formal JSON Schema](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) describes this handler-bearing source contract. Runtime device IDs and connection config belong to the host process and are reported dynamically during identity registration; they are deliberately not embedded in the plugin contract. `mdk-contract.json`, at your package root: ```json { "metadata": { "provider": "vendor", "deviceFamily": "miner", "brand": "Vendor", "modelsSupported": ["VENDOR_Q1"], "overview": "Controls Vendor miners running firmware v1's HTTP JSON API. Operations affect physical hardware — prioritize thermal safety." }, "capabilities": { "telemetry": [ { "name": "hashrate_rt", "unit": "TH/s", "type": "number", "handler": "src/telemetry/hashrate-rt.js", "description": "Real-time hashrate from /api/v1/summary." }, { "name": "power", "unit": "W", "type": "number", "handler": "src/telemetry/power.js", "description": "Current power draw." }, { "name": "temperature", "unit": "C", "type": "number", "handler": "src/telemetry/temperature.js", "description": "Hash board temperature. Above 85C requires intervention." } ], "commands": [ { "name": "reboot", "handler": "src/commands/reboot.js", "description": "Restarts the miner controller.", "constraints": "Do not call more than once per 5 minutes.", "params": [] }, { "name": "setPowerMode", "handler": "src/commands/set-power-mode.js", "description": "Changes the power mode.", "params": [ { "name": "mode", "type": "string", "required": true, "enum": ["eco", "normal", "high"] } ] } ], "health": { "supportedStates": ["OK", "DEGRADED", "OFFLINE"], "alerts": ["alert.overheat"], "troubleshooting": [ "If alert.overheat, verify fan speeds and ambient temperature before rebooting." ] }, "errors": { "ERR_MODE_REQUIRED": "The requesting client omitted the required power mode.", "ERR_MODE_TYPE": "The supplied power mode was not a string.", "ERR_BAD_POWER_MODE": "The supplied power mode is not allowed or the firmware rejected it.", "ERR_COMMAND_COOLDOWN": "The command was issued before its declared cooldown elapsed.", "ERR_COMMAND_IN_PROGRESS": "A command of this type is already running for the device.", "ERR_DEVICE_TIMEOUT": "The device operation exceeded its configured timeout.", "ERR_DEVICE_CALL_FAILED": "The v1 HTTP API call failed or returned an error." } } } ``` A few fields worth calling out because they aren't just documentation: - `description` is read by AI agents as the semantic boundary for that field — put the actual constraint in it (e.g. _"Above 85C requires intervention"_), not just a label - `WorkerRuntimeV2` normalizes positional parameters, then checks each declared `params` entry's `required`, `type`, `enum` and `min`/`max` before the handler runs, failing with `ERR_PARAM_*`. `constraints` is published metadata only: nothing enforces it. Validate in the handler as well, with your own stable `ERR_*` codes, because `createInstance` (Step 7) and any other direct caller skip the runtime's check, and enforce every declared cooldown there. - `verify` (optional, per command) tells Kernel how to confirm the physical result, e.g. `"verify": { "telemetry": "power_mode", "param": "mode", "withinMs": 60000 }`. After the command settles `SUCCESS` (or `TIMEOUT`), Kernel reads that telemetry entry back through your Worker until it equals the request's `mode` or `withinMs` (default 60000) passes, and reports `verification` `VERIFIED` or `UNVERIFIED` in `command.status`. Declare it only when a telemetry entry reports the exact value the param sets; without it, callers only ever learn that the handler returned. The readback runs your telemetry handler, so keep any device read cache shorter than the Kernel's verify interval (`commandVerifyIntervalMs`, default 5 s) or a stale reading can confirm the command - `errors` maps your device's error codes to human-readable text; throw `Error` messages that contain these codes so operators and agents can look them up - `health.alerts` is optional because a plugin without an alerting layer must not invent alerts. `metadata`, `capabilities.telemetry`, `capabilities.commands`, `capabilities.health.supportedStates`, and `capabilities.errors` are publication/catalogue requirements. At runtime, the current loader's minimum is looser: it requires `metadata` and `capabilities` objects plus valid handler entries. Treat the schema as the partner publication contract and the loader checks as fail-fast runtime validation, not two alternative formats. ### Write the telemetry and command handlers Every `handler` path in the contract resolves (relative to your package root) to a function with a fixed signature. `WorkerRuntimeV2` resolves every declared handler path when it loads the contract, and `require()`s it per device the first time that device's context opens. A missing file, a non-function export, or a duplicate name throws before your Worker serves a request (see Troubleshooting). **Every entry in `capabilities.telemetry` and `capabilities.commands` needs a matching file**: declaring `power` / `temperature` / `reboot` in the contract without writing those handlers fails. #### 4.1 Telemetry handler A telemetry handler is `async (params) => value`. The handler reads its own device straight from the ambient `@tetherto/mdk-worker/device` module, the same way `src/client.js` does in Step 2. Devices are isolated by construction: `WorkerRuntimeV2` loads your package's files into a private module registry per device, so `require("../client")` inside one device's handlers always resolves to that device's own client instance, never a sibling's. One file per telemetry field from Step 3, delegating to `src/client.js`: `src/telemetry/hashrate-rt.js`: ```js 'use strict' const client = require('../client') module.exports = async () => (await client.getSummary()).hashrate_ths ``` `src/telemetry/power.js`: ```js 'use strict' const client = require('../client') module.exports = async () => (await client.getSummary()).power_w ``` `src/telemetry/temperature.js`: ```js 'use strict' const client = require('../client') module.exports = async () => (await client.getSummary()).board_temp_c ``` #### 4.2 Command handler A command handler is `async (params) => result`. Return value becomes `payload.result`; a thrown `Error` becomes `{ status: 'FAILED', error: err.message }` in the response, which is how your `errors` map in the contract actually reaches the requesting client. One file per command from Step 3: `src/commands/reboot.js`: ```js 'use strict' const { id } = require('@tetherto/mdk-worker/device') const client = require('../client') const COOLDOWN_MS = 5 * 60 * 1000 // Module-level, not keyed by device: WorkerRuntimeV2 loads this file into a // private registry per device, so this state is already scoped to the one // device this instance was built for. let lastAttemptAt = 0 let running = false function audit (outcome, errorCode) { console.info( JSON.stringify({ event: 'physical_command', command: 'reboot', deviceId: id, outcome, ...(errorCode ? { errorCode } : {}) }) ) } function stableErrorCode (err) { const match = /ERR_[A-Z0-9_]+/.exec(err && err.message) return match ? match[0] : 'ERR_DEVICE_CALL_FAILED' } module.exports = async () => { const now = Date.now() if (running) { audit('rejected', 'ERR_COMMAND_IN_PROGRESS') throw new Error('ERR_COMMAND_IN_PROGRESS: reboot') } const remaining = COOLDOWN_MS - (now - lastAttemptAt) if (remaining > 0) { audit('rejected', 'ERR_COMMAND_COOLDOWN') throw new Error(`ERR_COMMAND_COOLDOWN: reboot ${remaining}ms`) } // Record the attempt before device I/O. A failed or timed-out reboot still // consumes the cooldown because the device may have accepted the command. lastAttemptAt = now running = true audit('started') try { const result = await client.reboot() audit('succeeded') return result } catch (err) { audit('failed', stableErrorCode(err)) throw err } finally { running = false } } ``` `src/commands/set-power-mode.js`: ```js 'use strict' const { id } = require('@tetherto/mdk-worker/device') const client = require('../client') const ALLOWED_MODES = new Set(['eco', 'normal', 'high']) function audit (outcome, errorCode) { console.info( JSON.stringify({ event: 'physical_command', command: 'setPowerMode', deviceId: id, outcome, ...(errorCode ? { errorCode } : {}) }) ) } function stableErrorCode (err) { const match = /ERR_[A-Z0-9_]+/.exec(err && err.message) return match ? match[0] : 'ERR_DEVICE_CALL_FAILED' } function reject (code) { audit('rejected', code) throw new Error(code) } module.exports = async (params) => { if (!params || params.mode === undefined) reject('ERR_MODE_REQUIRED') if (typeof params.mode !== 'string') reject('ERR_MODE_TYPE') if (!ALLOWED_MODES.has(params.mode)) reject('ERR_BAD_POWER_MODE') audit('started') try { const result = await client.setPowerMode(params.mode) audit('succeeded') return result } catch (err) { audit('failed', stableErrorCode(err)) throw err } } ``` For a numeric parameter declared with `"min": 0, "max": 100`, enforce both type and range explicitly and add both codes to `capabilities.errors`: ```js if (typeof params.percent !== 'number' || !Number.isFinite(params.percent)) { throw new Error('ERR_PERCENT_TYPE') } if (params.percent < 0 || params.percent > 100) throw new Error('ERR_PERCENT_RANGE') ``` `lastAttemptAt` and `running` above are deliberately process-local teaching state, scoped to one device by the runtime's per-device module registry rather than by a `Map` keyed on device ID. If a physical cooldown must survive restarts or multiple Worker hosts, store `lastAttemptAt` in process-owned persistent storage and update it atomically before device I/O. The JSON audit lines demonstrate the minimum event shape, including rejected and failed outcomes; production hosts must send these events to a durable audit sink. Actor identity and request correlation are owned by the authenticated Gateway/control plane because they are not currently present in the handler arguments. Never include credentials or raw device responses in audit events. Telemetry routing uses `query.type`, not the contract entry's return `type`. A request with `{ query: { type: "metrics" } }` invokes **every** telemetry handler and returns `{ metrics: { hashrate_rt: value, history: value, ... } }`; each handler error is isolated as `{ error: "..." }` under that key. A request with `{ query: { type: "history", limit: 20 } }` invokes only the telemetry entry named `history` and returns `{ name: "history", value }` or `{ error }`. The contract's `"type": "array"` describes the handler's returned value; it does not create the channel. A history-like handler is still included in the default `metrics` loop under the current runtime, so keep it bounded and inexpensive or change the runtime contract before relying on different behavior. Keep named-channel handlers defensive as callers can invoke them directly with untrusted query fields. [`WorkerRuntimeV2`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js) also auto-registers a builtin `health` channel on every device, with no contract entry required: a plugin that doesn't declare its own `health` telemetry handler still answers `{ query: { type: "health" } }` with `{ status: "OK", id, workerId }` (never `env`, `opts` or `config`, which can hold secrets and device credentials). Declaring a `health` entry in [`capabilities.telemetry`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) yourself overrides the builtin with your own handler. ```js await mdkClient.pullTelemetry(deviceId, 'health') // → { status: 'OK', id: 'wm-001', workerId: '...' } ``` ### Verify the plugin loads There is nothing left to assemble: `mdk-contract.json` at your package root, together with the handler files it declares under `src/`, is the complete, loadable Worker plugin. No index file exports it, and nothing turns it into an object for a runtime to consume; a host points `WorkerRuntimeV2` straight at your package directory. That does mean a broken handler wiring has nowhere to surface until something tries to load the directory. Catch it yourself with [`loadContract`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/index.js), the same function `WorkerRuntimeV2` calls internally: ```js 'use strict' const { loadContract } = require('@tetherto/mdk-worker') const loaded = loadContract(__dirname) console.log(loaded.publishedContract) // handler paths stripped, the shape Kernel receives ``` `loadContract` resolves every declared `handler` path on disk but never executes it. A missing file, a missing `handler` field, or a duplicate name throws immediately (see Troubleshooting). It cannot yet catch a handler file that exists but fails to load or does not export a function: that only happens once a device instance is built from it, which is what Step 7's tests exercise per handler. Every declared device reports `online` immediately; an unreachable one surfaces as an error inside the telemetry payload rather than holding the device `offline`. Whatever a handler module opens at load time (a socket, a file handle) lives until the process exits; nothing closes it automatically. ### Build a mock device Ship a standalone fake of your vendor's native API so anyone (including your own CI) can develop and test against your Worker without real hardware. It should know nothing about MDK; it's the same surface a real device on the LAN would present. `mock/server.js`, a trimmed version of the template's own `mock/server.js`: ```js 'use strict' const http = require('http') function createServer ({ host, port, hashrateThs, powerW }) { const state = { hashrateThs: hashrateThs || 180, powerW: powerW || 3400, boardTempC: 62, powerMode: 'normal' } const server = http.createServer((req, res) => { const reply = (code, body) => { res.writeHead(code, { 'content-type': 'application/json' }) res.end(JSON.stringify(body)) } if (req.method === 'GET' && req.url === '/api/v1/summary') { return reply(200, { hashrate_ths: state.hashrateThs, power_w: state.powerW, board_temp_c: state.boardTempC, power_mode: state.powerMode }) } if (req.method === 'POST' && req.url === '/api/v1/reboot') { return reply(200, { ok: true, rebooting: true }) } if (req.method === 'POST' && req.url === '/api/v1/power-mode') { let buf = '' req.on('data', (c) => { buf += c }) req.on('end', () => { const { mode } = JSON.parse(buf || '{}') state.powerMode = mode reply(200, { ok: true, power_mode: mode }) }) return } reply(404, { ok: false, error: 'ERR_NOT_FOUND' }) }) server.listen(port, host || '127.0.0.1') return { server, state, exit () { server.close() } } } module.exports = { createServer } ``` The mock must cover every device-client path your handlers call: summary fields for each telemetry handler, plus `/api/v1/reboot` for the reboot command (Step 2's `src/client.js` already defines that method). ### Test the plugin against the mock Drive [`loadContract`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/index.js) and [`createInstance`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/index.js) directly against the mock. This exercises your whole plugin (telemetry translation, command dispatch, error mapping) with **no** `WorkerRuntimeV2` in the loop, so it needs nothing beyond what you've already written in Steps 1–6. `tests/unit/handlers.test.js`: ```js 'use strict' const path = require('path') const test = require('brittle') const { loadContract, createInstance } = require('@tetherto/mdk-worker') const vendorMock = require('../../mock/server') const PKG_DIR = path.join(__dirname, '..', '..') function buildInstance ({ port, deviceId }) { return createInstance({ dir: PKG_DIR, entries: loadContract(PKG_DIR).entries, device: { id: deviceId, opts: { host: '127.0.0.1', port }, env: {}, config: {} } }) } test('directory-loaded plugin: every contract entry has a working handler module', (t) => { const loaded = loadContract(PKG_DIR) t.is(loaded.entries.telemetry.size, 3) t.is(loaded.entries.commands.size, 2) for (const entry of loaded.publishedContract.capabilities.telemetry) { t.is(entry.handler, undefined, `${entry.name} handler path stripped from published contract`) } // The boot rule proves out per instance: every resolved handler path // loads to a function once bound to a device. const instance = createInstance({ dir: PKG_DIR, entries: loaded.entries, device: { id: 'vendor-boot', opts: { host: '127.0.0.1', port: 1 }, env: {}, config: {} } }) for (const fn of instance.telemetry.values()) t.is(typeof fn, 'function') for (const fn of instance.commands.values()) t.is(typeof fn, 'function') }) test('telemetry and commands work against the mock', async (t) => { const auditEvents = [] const originalInfo = console.info console.info = (line) => auditEvents.push(JSON.parse(line)) t.teardown(() => { console.info = originalInfo }) const mock = vendorMock.createServer({ port: 9001, hashrateThs: 200 }) t.teardown(() => mock.exit()) const instance = buildInstance({ port: 9001, deviceId: 'vendor-0' }) t.is(await instance.telemetry.get('hashrate_rt')(), 200, 'hashrate_rt reads the mock') const result = await instance.commands.get('setPowerMode')({ mode: 'eco' }) t.is(result.power_mode, 'eco', 'command reaches the mock') await t.exception(() => instance.commands.get('setPowerMode')({}), /ERR_MODE_REQUIRED/) await t.exception(() => instance.commands.get('setPowerMode')({ mode: 1 }), /ERR_MODE_TYPE/) await t.exception( () => instance.commands.get('setPowerMode')({ mode: 'turbo' }), /ERR_BAD_POWER_MODE/ ) t.ok( auditEvents.some( (e) => e.command === 'setPowerMode' && e.outcome === 'rejected' ) ) }) test('a telemetry handler rejects when the device is unreachable', async (t) => { // Nothing is listening on this port. With no boot-time connect probe (see // Step 5), the instance itself builds fine; the failure moves to call time. const instance = buildInstance({ port: 9099, deviceId: 'vendor-offline' }) // fetch's connection-refused rejection is a TypeError, which plain // t.exception treats as an uncaught bug rather than an expected rejection. await t.exception.all(instance.telemetry.get('hashrate_rt')()) }) test('reboot enforces concurrency and cooldown after every attempt', async (t) => { const mock = vendorMock.createServer({ port: 9003 }) t.teardown(() => mock.exit()) const instance = buildInstance({ port: 9003, deviceId: 'vendor-concurrent' }) const first = instance.commands.get('reboot')() await t.exception(() => instance.commands.get('reboot')(), /ERR_COMMAND_IN_PROGRESS/) await first await t.exception(() => instance.commands.get('reboot')(), /ERR_COMMAND_COOLDOWN/) }) ``` `createInstance` builds one plugin instance for one device: the same call [`WorkerRuntimeV2`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js) makes per configured device at `runtime.start()`. Building two instances against distinct mocks and distinct `deviceId`s proves device isolation: a command against one instance never reaches the other's client, because each device's `src/client.js` was loaded into its own private module registry. Cover at minimum: a telemetry handler reading a live value from the mock, a command reaching the mock and returning a result, required/type/range/enum validation surfacing your contract's `ERR_*` codes, concurrent-command rejection, cooldown after successful and failed attempts, an unreachable device surfacing an error from the handler call rather than failing to build, and structured audit events containing rejected and failed outcomes. Production integration tests should also verify that the host forwards those events to its durable audit sink. Run it: ```bash npm install npm test ``` Expected output ends with: ```text # tests = 4/4 pass # asserts = 20/20 pass # ok ``` ### Write a README Document, for your own package's users: what hardware/firmware it targets, how to run the bundled mock, and a link to your `mdk-contract.json` as the field reference. You don't need to follow this monorepo's internal `USAGE.md` + `examples/` documentation-catalogue convention ([described here](https://github.com/tetherto/mdk/blob/main/backend/core/README.md)) — that exists to feed this repo's own generated hardware catalogue and docs-sync tooling, and doesn't apply to a package living outside it. ## Conformance checklist Before calling your Worker done: - [ ] `mdk-contract.json` validates against [`mdk-contract.schema.json`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json); every telemetry/command entry has a unique name and a CommonJS handler path that resolves to a function - [ ] Every `description` states the actual semantic boundary, not just a label — this is AI-reasoning surface, not decoration - [ ] Every device I/O operation has a finite timeout; safe read retries are bounded; physical writes are not automatically retried - [ ] Every command validates required values, types, ranges/enums, and declared cooldowns in the handler and maps failures to stable codes in `capabilities.errors` - [ ] Production command paths authenticate, authorize, rate-limit, optionally approve, and audit physical writes - [ ] Unreachable-device behavior (an error from the handler call, not a boot-time failure) and the host's recovery policy are documented - [ ] The mock lets a new partner developer run the Worker with zero real hardware - [ ] Tests cover: a telemetry pull, a command that targets one device without touching its siblings, and a validation/device error surfacing as `status: 'FAILED'` - [ ] A [Kernel-mediated test](/guides/workers/test-a-worker) asserts the Worker reaches `READY`, exposes its device IDs, and serves telemetry through `createMdkClient` - [ ] `npm run lint` and your test suite are wired into your own CI ## Troubleshooting Two distinct phases can fail, and telling them apart matters: contract loading validates your `mdk-contract.json` and resolves every handler path once, for the whole package; device instantiation `require()`s those handler files, once per device, the first time that device's context opens. **Contract loading**: `new WorkerRuntimeV2(dir, opts)` runs this synchronously before any device opens, and [`loadContract(dir)`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/index.js) (Step 5) runs the identical check on its own: | Error | Diagnostic and remediation | | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ | | `ERR_WORKER_DIR_REQUIRED` | `WorkerRuntimeV2`'s first argument must be a non-empty directory string | | `ERR_CONTRACT_DIR_REQUIRED` | `loadContract`'s argument must be a non-empty directory string | | `ERR_CONTRACT_NOT_FOUND: : ` | No `mdk-contract.json` at the package root; check the path | | `ERR_CONTRACT_INVALID_JSON: : ` | `mdk-contract.json` does not parse; fix the JSON syntax | | `ERR_PLUGIN_CONTRACT_METADATA_MISSING` | `metadata` is missing or not an object | | `ERR_PLUGIN_CONTRACT_CAPABILITIES_MISSING` | `capabilities` is missing or not an object | | `ERR_PLUGIN_SECTION_NOT_ARRAY:
` | `capabilities.telemetry` or `capabilities.commands` must be an array | | `ERR_PLUGIN_ENTRY_NAME_MISSING:
` | Give every telemetry/command entry a non-empty string `name` | | `ERR_PLUGIN_HANDLER_MISSING:
.` | Add that entry's relative `handler` path | | `ERR_PLUGIN_HANDLER_NOT_FOUND:
.: : ` | No file resolves at that path relative to the package root | | `ERR_PLUGIN_DUPLICATE_NAME:
.` | Rename or remove the duplicate entry in that section | **Device instantiation**: `runtime.start()` runs this per configured device (see [`worker-runtime-v2.js`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js)), and [`createInstance`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/index.js) (Step 7) runs the identical check for one device at a time in tests: | Error | Diagnostic and remediation | | ---------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `ERR_INSTANCE_HANDLER_NOT_FOUND: :
.: ` | The path that resolved fine at contract-load time no longer resolves inside this device's module context; check for a typo | | `ERR_INSTANCE_HANDLER_LOAD_FAILED: :
.: ` | The handler file exists but throws while loading; the nested error names the real failure (a missing import, a syntax error) | | `ERR_INSTANCE_HANDLER_NOT_FUNCTION: :
.` | The module must assign a function to `module.exports` | For errors from a live Kernel registration or requests once your Worker is actually hosted, see [Troubleshooting](/guides/workers/test-a-worker) in Test a Worker with MDK. ## Next steps - List your Worker in the catalogue, either: - [Raise an issue](https://github.com/tetherto/mdk/issues) and share the link to your Worker contract - Add a row to `external-workers.json` - Test your new [Worker's integration with MDK](/guides/workers/test-a-worker) - Understand the [security boundaries](/concepts/security-boundaries) - See the end-user experience of controlling and monitoring your device via the Worker in [Test a Worker's next steps](/guides/workers/test-a-worker) # Test a new Worker (/guides/workers/test-a-worker) This guide is for users of third-party worker packages or such partners who have integrated their own hardware, firmware, or data feed with MDK by shipping a [Worker plugin package](/guides/workers/build-a-worker). ## Overview Worker packages are the contract between the hardware and the Kernel, before relying on such a contract you will want to test its integration. To seed devices and register with Kernel, host the package on `WorkerRuntimeV2` in a Node.js host process by pointing it at the Worker plugin package's directory (see [Build a third-party Worker](/guides/workers/build-a-worker)). The host module may live in the Worker plugin package itself; a second npm package is **not required**. A separate host directory is recommended when independent plugin publication and plugin-only tests are useful: ```text your-worker-host/ index.js # host module: WorkerRuntimeV2, devices, lifecycle run-live.js # live Kernel registration and compatibility check ``` `your-worker-host/` is a directory holding one host module, not a standalone npm package. ## Prerequisites - Node.js `>=24` (all MDK core packages declare this `engines` constraint) - npm 11 [(< 12)](/reference/environment) - A completed [Worker plugin package](/guides/workers/build-a-worker), including its bundled mock device - Comfort with plain async JS — no additional MDK framework knowledge is required beyond what building the package already covered ### Check the package Run the [`mdk` CLI](/guides/cli/install) checks against the package directory before you host it. Both exit `1` on any problem, so the same commands work as a CI gate: ```bash mdk validate worker --path mdk validate worker --path --smoke ``` The first command reads `package.json` and `mdk-contract.json` without running anything. It checks the contract against its schema, that names are unique, that number params have valid `min`/`max` bounds, and that every handler file exists and compiles. `--smoke` also calls every handler against the package's bundled mock. `setup()` in `smoke.config.js` at the package root starts the mock and returns these fields: | Field | Purpose | | ------------------ | ----------------------------------------------------------------------------------------------------------- | | `config` | Device opts the handlers see, usually the mock's address and port | | `env` | Device env the handlers see | | `pluginConfig` | Plugin config the handlers see | | `commands` | Sample params per command name, each run against the mock. Leave out commands that reboot or disconnect a device. | | `teardown` | Stops the mock. Without it, `--smoke` skips the device-stopped checks and prints a warning. | | `offlineTelemetry` | Telemetry channels served from the Worker's own store, such as a `history` log, that keep answering once the device stops | While the mock runs, every telemetry channel must return its declared type, and every sample command must succeed. The param gate must reject each bounded number param just outside its range, and each command handler must itself reject a value below its first bounded param's `min`, since a Worker is reachable without the Kernel's gate in front of it. After `teardown()`, every telemetry channel and sample command must fail with an `ERR_*` error code before the deadline (`--timeout`, `10000` ms by default), so a stopped device never reports a stale reading. Two kinds of channel are exempt: - A `health` channel may report a status other than `OK` instead of failing - A channel listed in `offlineTelemetry` must still return its declared type ### Install MDK Install the packages this guide hosts and calls: ```bash npm install @tetherto/mdk-worker @tetherto/mdk-core @tetherto/mdk-client ``` `@tetherto/mdk-worker` ships `WorkerRuntimeV2`. `@tetherto/mdk-core` ships `getKernel`, `startGateway` and `waitForDiscovery`, used to [register with a live Kernel](#register-directly-with-a-live-kernel), and exposes the local discovery helpers at `@tetherto/mdk-core/lib/local-discovery`. `@tetherto/mdk-client` ships `createMdkClient` for the compatibility check. ### Write the host module `host/index.js`: ```js "use strict"; const path = require("path"); const { WorkerRuntimeV2 } = require("@tetherto/mdk-worker"); const WORKER_DIR = path.resolve(__dirname, "../your-worker-repo"); async function startVendorWorker({ workerId, kernelTopic, allowedKeys, seedDevices }) { const runtime = new WorkerRuntimeV2(WORKER_DIR, { workerId, kernelTopic: kernelTopic || null, allowedKeys, devices: (seedDevices || []).map((d) => ({ deviceId: d.id, config: d.opts, })), }); await runtime.start(); return { runtime, stop: () => runtime.stop(), }; } module.exports = { startVendorWorker }; ``` `WorkerRuntimeV2`'s first argument is the Worker plugin package's own directory, the same one that holds its `mdk-contract.json` (see [Build a third-party Worker](/guides/workers/build-a-worker)); there is no plugin module to `require()`. Required options are `workerId`, a non-empty `devices` array and `allowedKeys`. Each device's `config` object here becomes the ambient `opts` its handlers read from `@tetherto/mdk-worker/device`. `kernelTopic` is needed only for DHT discovery. Worker RPC is **fail-closed**: the runtime answers only the public keys in `allowedKeys` (or added later with `runtime.allowKey(key)`), and `runtime.start()` throws `ERR_WORKER_ALLOWED_KEYS_REQUIRED` when there are none. The key to allow is the Kernel's **client** key, `kernel.getClientPublicKey()`, the one it dials Workers with. Each of the next three steps gets that key a different way; see [Trust and admission](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md#trust-and-admission) for the full model. Without a `store`, `WorkerRuntimeV2` generates a new RPC keypair on restart. Pass a process-owned store if deployment requires stable identity. The host process also owns persistence, sampling loops, retries, secrets, and shutdown. `WorkerRuntimeV2` also exposes two read accessors for the host process: `getPublicKey()` returns the runtime's RPC public key (used to register with Kernel, shown in the next step), and `getDeviceContext(deviceId)` returns a frozen `{ deviceId, config, services }` for a device that is currently `online`, or `null` otherwise. There is no `device` key: a directory-loaded plugin has no per-device client object for the host to reach into, since handler modules bind to their device privately through the ambient context (see Step 4 of Build a third-party Worker). A host process that needs to act on a live device drives it the same way a Gateway request would, through `runtime.handleRequest(...)`, rather than through `getDeviceContext(...).device`. ### Register directly with a live Kernel When Kernel and the Worker host share a process, register the runtime's public key directly. The following host script (save it next to your worker as e.g. `host/run-live.js`) proves that Kernel accepted the Worker, that it reached `READY`, and that telemetry traverses the real client → Kernel → Worker path: ```js "use strict"; const os = require("os"); const path = require("path"); const { getKernel, waitForDiscovery, shutdown, } = require("@tetherto/mdk-core"); const { createMdkClient } = require("@tetherto/mdk-client"); const { callerKeyPair, trustKernel, } = require("@tetherto/mdk-core/lib/local-discovery"); const { startVendorWorker } = require("./index"); const vendorMock = require("../your-worker-repo/mock/server"); const ROOT = path.join(os.tmpdir(), `vendor-worker-${process.pid}`); function onceListening(mock) { if (mock.server.listening) return Promise.resolve(); return new Promise((resolve) => mock.server.once("listening", resolve)); } function withTimeout(promise, timeoutMs, code) { let timer; const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error(code)), timeoutMs); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } async function main() { let mock; let worker; let kernel; let client; try { mock = vendorMock.createServer({ host: "127.0.0.1", port: 9001, hashrateThs: 200, }); await onceListening(mock); kernel = await getKernel({ root: ROOT }); worker = await startVendorWorker({ workerId: "vendor-demo", allowedKeys: [kernel.getClientPublicKey()], seedDevices: [ { id: "vendor-0", opts: { host: "127.0.0.1", port: 9001 } }, ], }); // Follow every caller the Kernel admits later (a Gateway, for instance). trustKernel(worker.runtime, kernel); await kernel.registerWorker(worker.runtime.getPublicKey()); const workers = await waitForDiscovery(kernel, { minWorkers: 1, timeoutMs: 30000, }); const ready = workers.find( (w) => w.workerId === "vendor-demo" && w.state === "READY", ); if (!ready || !ready.deviceIds.includes("vendor-0")) { throw new Error("ERR_WORKER_NOT_READY"); } // The Kernel is fail-closed too: admit this script's own caller key, read-only. const caller = callerKeyPair(path.join(ROOT, "caller")); kernel.allowKey(caller.publicKey, ["read"]); client = createMdkClient({ kernelKey: kernel.getPublicKey(), kernelKeyPair: caller, }); await client.connect(); const telemetry = await withTimeout( client.pullTelemetry("vendor-0", "metrics"), 8000, "ERR_TELEMETRY_TIMEOUT", ); if (typeof telemetry.metrics?.hashrate_rt !== "number") { throw new Error("ERR_TELEMETRY_INVALID"); } console.log(`READY ${ready.workerId}: ${ready.deviceIds.join(", ")}`); console.log(`hashrate_rt=${telemetry.metrics.hashrate_rt}`); } finally { if (client) await client.close(); if (kernel) await shutdown(kernel); if (worker) await worker.stop(); if (mock) mock.exit(); } } main().catch((err) => { console.error(err); process.exitCode = 1; }); ``` Expected output: ```text READY vendor-demo: vendor-0 hashrate_rt=200 ``` The timeout wrapper bounds the client's wait but cannot cancel the current HRPC request. Always close the client during shutdown. Device-protocol cancellation is separately owned by the device client from Step 2. ### Use local-directory discovery on one machine When the Worker host and Kernel are separate processes on the same machine, neither `registerWorker()` nor a DHT topic is needed: the Worker publishes its RPC key to a shared directory and Kernel picks it up from there. Both sides default that directory to `/.worker-keys`, so passing the same `root` is the whole configuration. ```js "use strict"; const os = require("os"); const path = require("path"); const { getKernel, waitForDiscovery, shutdown, } = require("@tetherto/mdk-core"); const { keysDir, publishWorkerKey, trustedKeysDir, trustKeysFromDir, waitForKernelKey, } = require("@tetherto/mdk-core/lib/local-discovery"); const { startVendorWorker } = require("./index"); const ROOT = path.join(os.tmpdir(), `vendor-worker-local-${process.pid}`); async function main() { let worker; let trusted; let kernel; try { // Kernel side: publishes its client key to /.trusted-keys. kernel = await getKernel({ root: ROOT, discovery: { mode: "local" } }); // Worker side: allow the Kernel's published key, then every key published // there later, and publish the Worker's own RPC key for discovery. const trustDir = trustedKeysDir(ROOT); worker = await startVendorWorker({ workerId: "vendor-demo", allowedKeys: [await waitForKernelKey(trustDir)], seedDevices: [ { id: "vendor-0", opts: { host: "127.0.0.1", port: 9001 } }, ], }); trusted = trustKeysFromDir(worker.runtime, trustDir); publishWorkerKey( keysDir(ROOT), "vendor-demo", worker.runtime.getPublicKey().toString("hex"), ); const workers = await waitForDiscovery(kernel, { minWorkers: 1, timeoutMs: 30000, }); const ready = workers.find( (w) => w.workerId === "vendor-demo" && w.state === "READY", ); if (!ready) throw new Error("ERR_WORKER_NOT_READY"); console.log(`READY ${ready.workerId}: ${ready.deviceIds.join(", ")}`); } finally { if (trusted) trusted.stop(); if (kernel) await shutdown(kernel); if (worker) await worker.stop(); } } main().catch((err) => { console.error(err); process.exitCode = 1; }); ``` The two halves are shown in one script here so it runs as a single check, but the mode exists for the split case: the Kernel process publishes its client key, and the Worker process waits for it (`waitForKernelKey` gives up after 30 seconds with `ERR_KERNEL_CLIENT_KEY_NOT_FOUND`) before it starts and publishes its own key. Kernel rescans the worker-keys directory every four seconds on top of watching it, so a Worker whose RPC server is not yet listening is retried on a later scan. Pass a `storeDir` to `WorkerRuntimeV2` if you restart Workers. The published key is only stable across restarts when the runtime has a store to persist its seeds in (see [Write the host module](#write-the-host-module)); without one, every restart looks like a new peer to Kernel and stale `.key` files accumulate. Every process must resolve the same filesystem path, so this mode only covers one machine. Workers on separate hosts need DHT discovery, in the next step. ### Use DHT discovery across processes or hosts For DHT discovery, generate and securely distribute one 32-byte hex topic. Admission is fail-closed in both directions, and nothing is shared automatically across hosts: give the Worker the Kernel's client key (`kernel.getClientPublicKey()`, stable across restarts of the same Kernel `root`) in `allowedKeys`, and give the Kernel the Worker's RPC key, in `discovery.workerKeys` at start or with `kernel.allowWorkerKey()` later. Kernel ignores a Worker whose key it was not given. Do **not** also call `registerWorker()`: ```js "use strict"; const crypto = require("crypto"); const os = require("os"); const path = require("path"); const { getKernel, waitForDiscovery, shutdown, } = require("@tetherto/mdk-core"); const { startVendorWorker } = require("./index"); const ROOT = path.join(os.tmpdir(), `vendor-worker-dht-${process.pid}`); async function main() { const topic = process.env.MDK_TOPIC || crypto.randomBytes(32).toString("hex"); let worker; let kernel; try { kernel = await getKernel({ root: ROOT, topic }); worker = await startVendorWorker({ workerId: "vendor-demo", kernelTopic: topic, // On separate hosts, copy this hex key to the Worker host's config. allowedKeys: [kernel.getClientPublicKey().toString("hex")], seedDevices: [ { id: "vendor-0", opts: { host: "10.0.0.20", port: 9001 } }, ], }); kernel.allowWorkerKey(worker.runtime.getPublicKey().toString("hex")); const workers = await waitForDiscovery(kernel, { minWorkers: 1, timeoutMs: 45000, }); const ready = workers.find( (w) => w.workerId === "vendor-demo" && w.state === "READY", ); if (!ready) throw new Error("ERR_WORKER_NOT_READY"); console.log(`READY ${ready.workerId}: ${ready.deviceIds.join(", ")}`); } finally { if (kernel) await shutdown(kernel); if (worker) await worker.stop(); } } main().catch((err) => { console.error(err); process.exitCode = 1; }); ``` For separate production processes, each process must install signal handlers and close every handle it owns. DHT topics enable rendezvous; they are not authentication secrets or command-authorization tokens. See the [discovery model](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/architecture.md#discovery-model) for how the three modes compare. ## Troubleshooting ### Runtime construction `new WorkerRuntimeV2(dir, opts)` runs two phases synchronously: it loads and validates the Worker plugin package at `dir` first (see [Troubleshooting](/guides/workers/build-a-worker) in Build a third-party Worker for `ERR_WORKER_DIR_REQUIRED` and the contract/handler errors), then validates `opts`: | Error | Diagnostic and remediation | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `ERR_WORKER_ID_REQUIRED` | Pass a non-empty string `workerId` | | `ERR_WORKER_ALLOWED_KEYS_REQUIRED` | Thrown by `runtime.start()`: pass the Kernel's client key in `allowedKeys` (Steps 3–5 show where it comes from). `allowAll: true` admits every peer and is for local experiments only | | `ERR_DEVICES_REQUIRED` | Pass a non-empty `devices` array, unless this is an intentional provisioning-first host using `allowEmptyDevices` | | `ERR_DEVICE_ID_MISSING` | Every device spec needs a non-empty string `deviceId` | | `ERR_DEVICE_ID_DUPLICATE: ` | Device IDs must be unique within one runtime | | `ERR_DEVICE_CONFIG_INVALID: ` | `config`, when supplied, must be a non-null object | `allowEmptyDevices` opts a host into a provisioning-first bootstrap: the runtime constructs with zero devices instead of throwing `ERR_DEVICES_REQUIRED`, then takes `registerThing` writes (a built-in command, see [Worker Runtime store services](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/README.md#store-services)) that persist new device configs to the store. Those writes only take effect once the host is stopped and restarted with the provisioned set — there is no hot-add. It is off by default; every shipped miner Worker in this monorepo sets it to `true` in its boot function. ### Startup and discovery | Symptom | Diagnostic and remediation | | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `waitForDiscovery()` returns no `READY` Worker | For direct registration, await `runtime.start()` and `kernel.registerWorker(runtime.getPublicKey())`. For DHT, start the Worker first, verify both processes use the same 32-byte hex topic and can reach the DHT network, and that the Worker's RPC key is in Kernel's `discovery.workerKeys` (or admitted with `kernel.allowWorkerKey()`) | | Worker is present but never `READY` | Inspect identity and capability failures. Confirm at least one device ID is reported and the contract has valid `metadata` and `capabilities` | Every device reports `online` as soon as `runtime.start()` returns; a directory-loaded plugin has no boot-time probe, so an unreachable device is never a startup symptom (see [Step 5](/guides/workers/build-a-worker) of Build a third-party Worker). If a device is unreachable, look for it at request time instead, in the table below. ### Request time | Error | Diagnostic and remediation | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ERR_DEVICE_NOT_FOUND: ` | The request targeted an ID not seeded in this runtime; compare it with the Kernel registry's `deviceIds` | | `ERR_DEVICE_ID_REQUIRED: ` | A named telemetry pull omitted its target device ID | | `ERR_DEVICE_ID_REQUIRED` | A command omitted its target device ID | | `ERR_UNKNOWN_QUERY_TYPE: ` | Use `metrics` or the exact `name` of a telemetry entry; the entry's return `type` is not its channel name | | `ERR_UNKNOWN_COMMAND: ` | Use the exact declared command name and confirm its handler loaded | | `ERR_UNKNOWN_ACTION: ` | Use a public MDK client helper instead of constructing protocol actions manually | | Command returns `status: 'FAILED'` | Read the stable `ERR_*` value, check validation/cooldown/device logs, and do not retry a timed-out physical write until its actual device state is known | An unreachable device does not surface as `ERR_DEVICE_UNAVAILABLE` for a directory-loaded plugin: with no `connect()` probe and no offline state, the failure comes back from inside the handler's own response instead, isolated to the telemetry channel that touched the network (`{ error: '...' }` under that channel's key in `metrics`, or `status: 'FAILED'` for a command); see the [directory-loaded plugin model](/guides/workers/build-a-worker) in Build a third-party Worker. ## Next steps - Understand the [security boundaries](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md#security-model) Understand the end-user experience of controlling and monitoring your device via the Worker: - Build a [minimal dashboard](/tutorials/build-a-dashboard) around one Worker - Run the [Starter site example](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/README.md) with a supervised, multi-Worker fleet - Connect [the operator agent](/guides/agent) to query and command your Workers over MCP # Reference (/reference) The Reference section indexes the canonical specs for everything MDK exposes: field semantics, signatures, transition rules, and contracts. Reach for it when you need exact shapes. ## Browse by stack area ### App toolkit - **UI Devkit**: [components](/reference/ui/components/), [hooks](/reference/ui/hooks/), [types](/reference/ui/types/), and [utilities](/reference/ui/utilities/) for the React UI Devkit ### Kernel - **[Kernel](/reference/kernel/)**: kernel module specs, state machines, transition tables, and recovery behavior ### MDK protocol - **[Protocol](/reference/protocol/)**: envelope schema, request/response examples, action catalogue, and base command set - *Capability contract*: coming soon ### Hardware - **[Supported hardware](/reference/supported-hardware/)**: miners, containers, power meters, sensors, and mining-pool integrations ### Workers - **[Workers](/reference/worker/)**: device protocol adapters that wrap vendor hardware APIs and expose them through the MDK Protocol ## Next steps - [Architecture](/concepts/architecture) for narrative explanations - [Try the demo](/tutorials/run-a-site) for step-by-step instructions # Environment requirements (/reference/environment) ## Overview Every guide and tutorial in this repo assumes the same baseline; this page explains the npm ceiling. Runtime environment variables are documented by the package that reads them: the `worker.js` variables in the [core SDK README](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md#trust-and-admission), and the full set a managed site touches in the [hosted-site settings table](/guides/deployment/hosted-site#gateway-and-kernel-settings). ## Requirements - [Node.js](https://nodejs.org/) >=24 (LTS) - npm 11 (< 12) - Git (latest stable version) Check the installed npm version with `npm --version`. ## Why npm stays below 12 npm 12 disables fetching git-based dependencies by default and fails with `EALLOWGIT`: ```text npm error code EALLOWGIT npm error Fetching packages of type "git" have been disabled ``` `examples/mvp-site` takes six third-party Workers as git dependencies, each pinned to a commit in a vendor-controlled repository: - `spiderpool-mdk-worker` - `emcd-mdk-worker` - `whatsminer-mdk-worker` - `antminer-mdk-worker` - `foundry-mdk-worker` - `sealminer-mdk-worker` On npm 12, `npm install` fails on the first of these before it reaches anything else. The ceiling lifts only once every one of them resolves from the registry, so it is not a single pending fix. If `npm --version` already reports 12 or higher, repin with: ```bash npm install -g npm@11 ``` # Glossary (/reference/glossary) This page provides explanations for terms that new users may not be familiar with. - [Stack](#stack-and-hardware-terms) - [HRPC](#hyperswarm-rpc) ## Stack and hardware terms This section explains the terms you need to familiarize yourself with, using an Antminer rack as an example. | Term | What it is | Lives at | | --- | --- | --- | | **Kernel** (Orchestration Kernel) | The pull-only kernel that owns the device registry, routes commands, and pulls telemetry on its own cadence — it performs no aggregation itself | [`backend/core/kernel/`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md) | | **Gateway** | The developer-owned HTTP entry point between non-Node clients (UI) and Kernel. Required for browser and HTTP consumers; AI agents reach Kernel through the standalone MCP server instead. Not used in the in-process Antminer-rack example below | [`backend/core/gateway/`](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) | | **Worker** | A device-family translator. Speaks the MDK Protocol upward to Kernel and the vendor's native API downward to one device family (one miner brand, one container type, one pool API). | [`backend/workers/`](/reference/worker) | | **App** | A publishable package serving HTTP routes and, optionally, dashboard UI: an [`mdk-app.yaml`](/guides/gateway/apps#create-an-app) manifest, an app route table with its controllers, and a `ui/` module. Installed into a stack with `mdk add app` and declared under `spec.apps` | [`backend/apps/demo/`](https://github.com/tetherto/mdk/blob/main/backend/apps/demo/README.md) | | **App route table** | The declarative `mdk-plugin.json` inside an app: its routes, their handlers and their HTTP methods and paths, read by the app runtime to mount them and by the Gateway to know where to forward | [`backend/apps/demo/mdk-plugin.json`](https://github.com/tetherto/mdk/blob/main/backend/apps/demo/mdk-plugin.json) | | **Worker Plugin** | The executable device integration: [`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) plus its handler files, loaded from a package directory by `WorkerRuntimeV2` | [`backend/workers/miners/avalon/plugin/index.js`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/avalon/plugin/index.js) | | **Worker contract** | The declarative [`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) alone, as read, validated, published, rendered, or queried | [`backend/workers/miners/avalon/plugin/mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/avalon/plugin/mdk-contract.json) | | **Driver class** | The JavaScript class a Worker exports, one per device family (for example `AvalonMiner`), not one per model. Drives every device that Worker registers. | [`backend/workers/miners/avalon/lib/avalon.miner.js`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/avalon/lib/avalon.miner.js) | | **Thing** | Deprecated: one registered device instance on the legacy [store services surface](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/README.md#store-services). Created by sending a `registerThing` command to the Worker's provisioning service, not by calling a driver-class method directly. Identified by a generated `deviceId`. A `WorkerRuntimeV2` Worker has no Things: its device list is fixed at construction. | [`backend/core/mdk/lib/services/provisioning.service.js`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/lib/services/provisioning.service.js) | | **MCP** (Model Context Protocol) | The protocol AI agents use to discover and call tools. `@tetherto/mdk-mcp` runs as its own standalone process, deriving tools from an app's routes; the Gateway hosts no MCP itself | [`backend/core/mcp/`](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) | | **HyperDHT** | The peer-to-peer network HRPC rides on. Every Kernel, Worker, Gateway and client starts a HyperDHT node in every deployment shape, so each process needs outbound UDP to the bootstrap nodes | [`hyperdht`](https://github.com/holepunchto/hyperdht) | | **Bootstrap nodes** | The well-known peers a HyperDHT node contacts on start to join the network. The public set is the default; a private DHT network supplies its own bootstrap list to each component | [`backend/core/mdk/README.md`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md) | | **Discovery mode** | How Kernel first learns a Worker's RPC key: in-process registration, a shared directory on one machine, or a DHT topic. The transport is the same HRPC in all three | [`backend/workers/docs/architecture.md`](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/architecture.md#discovery-model) | | **Discovery topic** | A 32-byte hex rendezvous value Kernel and Workers join in DHT mode so Kernel can find them. It is deployment configuration, not a credential: admission is the Kernel's `discovery.workerKeys` list | [`backend/core/kernel/README.md`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md) | | **READY** | The Worker registry state reached once Kernel has the Worker's capability response. It means Kernel can route to the Worker's devices, not that any device answered | [`backend/core/kernel/README.md`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md) | | **HEALTHY, SICK, DEAD** | The health states Kernel assigns from `health.ping`, which the Worker process answers from memory. `DEAD` stops command routing and nothing else. None of the three describes a device; the control plane explains where device liveness lives | [`backend/core/kernel/README.md`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md) | ### How they compose, for an Antminer rack ```mermaid flowchart TB subgraph clientLayer ["Your code"] Client["your script (e.g., client.js)"] end subgraph kernel ["Kernel"] Kernel["Kernel
device registry · command routing · telemetry pull"] end subgraph workerLayer ["Antminer Worker"] AntminerWorker["e.g., AM_S21PRO"] end subgraph devices ["Antminer devices (real or mock)"] Miners["Antminers (HTTP / digest auth)"] end Client -->|"HRPC"| Kernel Kernel -->|"HRPC"| AntminerWorker AntminerWorker --> Miners ``` The same shape repeats for every other device family (Whatsminer, container vendors, pool APIs). [Scalability](/concepts/scalability) covers the multi-Worker view, parallel Workers, and multi-site deployments. ## Hyperswarm RPC MDK uses [`@hyperswarm/rpc`](https://github.com/holepunchto/rpc) as its runtime transport. Hyperswarm RPC (HRPC) is not an HTTP-based RPC system. It is an RPC layer that rides on Hyperswarm peer-to-peer connectivity. The library is a simple RPC over the Hyperswarm DHT, backed by `Protomux`. Think of it as a peer-to-peer remote function call system built on a DHT and an encrypted connection layer. **Mental model** — Hyperswarm finds peers and establishes connections; `Protomux` divides the connection into named channels; RPC defines the conversation — a caller names a method and receives a reply. A useful analogy is a phone call between peers — Hyperswarm helps the phones find each other and connect; `Protomux` splits the line into channels; RPC defines how one side asks for a method and the other side responds. **Practical implications:** - A caller must hold or discover the callee's public key before connecting; there is no HTTP URL to open in a browser - You work with services, methods, requests, and responses — not URLs and routes - The RPC-shaped API is identical across same-process, same-host, and distributed deployments; only the discovery mechanism changes (same-process registration, shared directory, or DHT topic) - Peers discover and communicate without a central HTTP server HRPC uses Noise to encrypt connections and authenticate peers by key, so these backend connections do not require TLS certificates. It does not expose an HTTP listening endpoint for each peer. HyperDHT still uses network sockets, so UDP reachability, network policy, and caller allowlists remain part of the deployment. The Gateway's HTTP interface has its own security requirements. ### HRPC on the same host MDK uses HRPC as the single transport across all deployment shapes — same-process, same-host, and distributed. Every component is addressed by its public key, not by a socket path or hostname. The Gateway, a standalone Node.js script, and a remote service all connect the same way: ```js createMdkClient({ kernelKey: key }) ``` The Noise handshake that HRPC performs on every connection authenticates by key, so Kernel's allowlist works identically whether the caller is on the same machine or a remote host. This is consistent with the broader Holepunch ecosystem philosophy — everything is a peer addressed by public key. When the peer is on the same machine it routes locally over the local network interface; the application code sees no difference. The DHT node still starts and contacts its bootstrap nodes, so a same-host deployment needs that outbound reachability too. ## Next steps - You are ready to run the example in [Run a mining site end to end](/tutorials/run-a-site) - Learn more about: - Multi-process discovery across machines: [Worker discovery](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/architecture.md#discovery-model) - Gateway implementation details, including HTTP routing and how apps are served: [`backend/core/gateway/README.md`](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) - Building your own Worker for a new device family: [the build walkthrough](/guides/workers/build-a-worker) - The install and run pattern every shipped Worker package follows: [`backend/workers/docs/install-pattern.md`](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/install-pattern.md) - Per-device contract details (telemetry units, command shapes, error codes): those live in each Worker's `mdk-contract.json`, e.g. [`backend/workers/miners/avalon/plugin/mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/avalon/plugin/mdk-contract.json) # Kernel reference (/reference/kernel) `@tetherto/mdk-kernel` is the orchestration kernel of the MDK stack. This subsection holds the canonical specs for its internal modules. For the architectural narrative explaining how the Kernel fits into the rest of the stack, see [Architecture](/concepts/architecture). ## What's documented - **[Modules](/reference/kernel/modules)**: per-module responsibility, interfaces, state machines, transition rules, crash-recovery procedures, and scaling characteristics # Kernel modules (/reference/kernel/modules) ## Overview [Kernel](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/index.js)'s coordination splits across single-purpose modules. Each owns its own state, persistence boundary, and scaling characteristics. It communicates with the others only through its declared interface. The [Kernel's Architecture overview](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#architecture) provides the canonical spec for each module's interfaces, state machine, and recovery behavior. ## Modules - [`WorkerRegistry`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#workerregistry): holds two flat indexes, `deviceId` to its owning Worker and `workerId` to that Worker's record, and drives each Worker through its registration lifecycle - [`CommandDispatcher`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#commanddispatcher): validates an incoming command, resolves the target device or devices, and hands off to the Command State Machine - [`CommandStateMachine`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#commandstatemachine): tracks every command's execution lifecycle in a write-ahead log - [`TelemetryCollector`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#telemetrycollector): a proxy that routes telemetry queries to the Worker that owns the data, persisting none of it - [`Scheduler`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#scheduler): the system metronome that fires the recurring telemetry, health, and state jobs - [`HealthMonitor`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#healthmonitor): pings every ready Worker process over HRPC on a cadence and marks dead ones unroutable; it never contacts a device - [`ActionManager`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#actionmanager): handles the write action approval lifecycle at the Kernel layer - [`ActionCaller`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#actioncaller): resolves an approved action into the per-Worker write calls that carry it out ## Next steps - Review the [Protocol messages](/reference/protocol/messages): the actions these modules route and execute - See the [Kernel architecture](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md): the architectural narrative behind this module split - Understand [approval-gated writes](https://github.com/tetherto/mdk/blob/main/docs/concepts/control-plane.md#approval-gated-writes): the cross-layer flow [`ActionManager`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#actionmanager) and [`ActionCaller`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#actioncaller) implement # Packages (/reference/packages) ## Overview This page lists every package in the MDK monorepo. Core is the Kernel, the Gateway, and the runtimes around them. Extensions are the Workers and Apps that connect MDK to hardware, pools, and agents. UI is the browser toolkit, and Tools are what you develop with. Availability says how you get a package: - `workspace`: private to the monorepo, used from a clone - `publishable`: not marked private, so it can be published to a registry ## Core | Package | Purpose | Availability | | --- | --- | --- | | [`@tetherto/mdk-agent`](https://github.com/tetherto/mdk/blob/main/backend/core/agent/README.md) | MDK operator agent — headless loop over a local (QVAC) model, consuming MDK tools via MCP | `workspace` | | [`@tetherto/mdk-app-runtime`](https://github.com/tetherto/mdk/blob/main/backend/core/app-runtime/README.md) | MDK app runtime: runs one app's controllers in its own process and serves them over HRPC | `publishable` | | [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) | MDK protocol client — HRPC (RPC listener) transport for Kernel | `publishable` | | [`@tetherto/mdk-core`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md) | MDK bootstrap utilities and worker-infra services — kernel/gateway lifecycle plus injectable log/comment/alert/stats/settings/provisioning services for runtime-hosted workers | `publishable` | | [`@tetherto/mdk-gateway`](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) | MDK Gateway | `publishable` | | [`@tetherto/mdk-kernel`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md) | MDK Kernel - Orchestration Kernel | `publishable` | | [`@tetherto/mdk-mcp`](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) | MDK MCP Server | `publishable` | | [`@tetherto/mdk-worker`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/README.md) | MDK Worker Runtime — hosts a Worker Plugin's devices behind one HRPC channel to the Kernel | `publishable` | ## Extensions ### Miners | Name | Package | Purpose | Models | Availability | | --- | --- | --- | --- | --- | | Avalon | [`@tetherto/mdk-worker-avalon`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/avalon/README.md) | Controls Canaan Avalon Bitcoin miners over the CGMiner ASCII API | A1346 | `workspace` | ### Containers | Name | Package | Purpose | Models | Availability | | --- | --- | --- | --- | --- | | Antspace | [`@tetherto/mdk-worker-antspace`](https://github.com/tetherto/mdk/blob/main/backend/workers/containers/antspace/README.md) | Controls Antspace container cooling systems | HK3, IMM | `workspace` | | Bitdeer | [`@tetherto/mdk-worker-bitdeer`](https://github.com/tetherto/mdk/blob/main/backend/workers/containers/bitdeer/README.md) | Controls Bitdeer D40 immersion containers | D40-A1346, D40-M30, D40-M56, D40-S19XP | `workspace` | ### Power meters | Name | Package | Purpose | Models | Availability | | --- | --- | --- | --- | --- | | ABB | [`@tetherto/mdk-worker-abb`](https://github.com/tetherto/mdk/blob/main/backend/workers/power-meter/abb/README.md) | Reads ABB power meters via Modbus TCP | B23, B24, M1M20, M4M20, REU615 | `workspace` | | SATEC | [`@tetherto/mdk-worker-satec`](https://github.com/tetherto/mdk/blob/main/backend/workers/power-meter/satec/README.md) | Reads SATEC PM180 power meters via Modbus TCP | PM180 | `workspace` | | Schneider Electric | [`@tetherto/mdk-worker-schneider`](https://github.com/tetherto/mdk/blob/main/backend/workers/power-meter/schneider/README.md) | Reads Schneider Electric power meters via Modbus TCP | P3U30, PM5340 | `workspace` | ### Sensors | Name | Package | Purpose | Models | Availability | | --- | --- | --- | --- | --- | | Seneca | [`@tetherto/mdk-worker-seneca`](https://github.com/tetherto/mdk/blob/main/backend/workers/temperature/seneca/README.md) | Reads Seneca Z-4RTD-2 temperature sensors via Modbus TCP | Z-4RTD-2 | `workspace` | ### Mining pools | Name | Package | Purpose | Models | Availability | | --- | --- | --- | --- | --- | | F2Pool | [`@tetherto/mdk-worker-f2pool`](https://github.com/tetherto/mdk/blob/main/backend/workers/minerpools/f2pool/README.md) | Reports F2Pool mining pool account data: hashrate, workers, balances and revenue | F2POOL-BTC | `workspace` | | Ocean | [`@tetherto/mdk-worker-ocean`](https://github.com/tetherto/mdk/blob/main/backend/workers/minerpools/ocean/README.md) | Reports Ocean.xyz mining pool account data: hashrate, workers, balances and revenue | OCEAN-BTC | `workspace` | ### Plugins and tooling | Package | Purpose | Availability | | --- | --- | --- | | [`@tetherto/mdk-app-agent`](https://github.com/tetherto/mdk/blob/main/backend/apps/agent/README.md) | MDK agent app: auth-gated chat sessions, SSE streams and approval round-trips behind the Gateway | `workspace` | | [`@tetherto/mdk-worker-mock`](https://github.com/tetherto/mdk/blob/main/backend/workers/mock/README.md) | Generic mock framework for MDK device workers (BaseMock + transport adapters) | `workspace` | ## UI | Package | Purpose | Availability | | --- | --- | --- | | [`@tetherto/mdk-react-adapter`](https://github.com/tetherto/mdk/blob/main/ui/packages/react-adapter/README.md) | React framework adapter for @tetherto/mdk-ui-foundation. Binds Zustand vanilla stores and TanStack QueryClient to React-native hooks. No Redux | `publishable` | | [`@tetherto/mdk-react-devkit`](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/README.md) | MDK Devkit (React) — generic UI primitives (src/primitives) plus mining-domain components, hooks, and state bindings (src/domain) | `publishable` | | [`@tetherto/mdk-ui-agent`](https://github.com/tetherto/mdk/blob/main/ui/packages/ui-agent/README.md) | Injectable operator Co-pilot chat UI for the MDK agent app. Streams the six-event agent contract over SSE and renders it as a docked panel or a full-page route | `publishable` | | [`@tetherto/mdk-ui-foundation`](https://github.com/tetherto/mdk/blob/main/ui/packages/ui-foundation/README.md) | Framework-agnostic headless foundation for the MDK Devkit. Currently exposes Zustand vanilla stores and a TanStack QueryClient factory. No React | `publishable` | ## Tools | Package | Purpose | Availability | | --- | --- | --- | | [`@tetherto/mdk-cli`](https://github.com/tetherto/mdk/blob/main/packages/cli/README.md) | MDK command-line tool: onboard, scaffold, run and inspect an MDK stack | `publishable` | | [`@tetherto/mdk-skill`](https://github.com/tetherto/mdk/blob/main/packages/mdk-skill/README.md) | MDK Developer Skill Suite — Agent Skills (SKILL.md) bundle assembled from the MDK monorepo's real artifacts and versioned to track the MDK release line. Copy-only assembler: each library owns its artifacts, this package curates them into dist/skills/ and installs them flat into .cursor/skills/ or .claude/skills/ | `publishable` | ## Next steps - Check [which models each Worker supports](/reference/supported-hardware) - Learn how to [connect your own hardware](/guides/workers/build-a-worker) with a new Worker # Protocol reference (/reference/protocol) The MDK Protocol is the contract that crosses every layer of the stack: Workers, `@tetherto/mdk-kernel`, and the Gateway all exchange the same envelope. This subsection holds the canonical specs. For the architectural narrative explaining how the protocol fits together, see [Architecture](/concepts/architecture#why-hrpc). ## What's documented - **[Messages](/reference/protocol/messages)**: envelope schema, request/response examples, the full action catalogue, and the base command set. # Protocol messages (/reference/protocol/messages) ## Overview Every MDK Protocol message uses the same envelope regardless of which layers are talking. This page shows the envelope shape and one worked example. ## Envelope ```json { "id": "uuid-v4", "version": "0.2.0", "type": "request | response | event", "action": "", "sender": "", "target": " | null", "deviceId": "string | null", "timestamp": 1711640000000, "payload": {} } ``` External consumers (UI or AI agents) only provide `deviceId`. [Kernel](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/index.js) resolves the target Worker identity internally. A concrete request and response pair, end to end: ```json // request: Gateway asks Kernel to reboot device wm-001 { "id": "8d1c-e3a4", "version": "0.2.0", "type": "request", "action": "command.request", "sender": "gateway", "target": null, "deviceId": "wm-001", "timestamp": 1711640000000, "payload": { "command": "reboot" } } // response: Kernel relays the Worker's terminal result { "id": "1f9b-77c2", "version": "0.2.0", "type": "response", "action": "command.result", "sender": "kernel:kernel:shard-1", "target": "gateway", "deviceId": "wm-001", "timestamp": 1711640002145, "payload": { "status": "SUCCESS", "elapsedMs": 2145 } } ``` ## Next steps - Learn more about actions and command targeting: - The [Kernel README](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#mdk-protocol) holds the full action catalogue (worker discovery, scheduled polling, command dispatch, kernel queries, and the write action lifecycle) and [command targeting rules](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#command-control) (`payload.scope`'s `device`, `worker`, and `rack` values, and the 1024-target cap) - [Approval-gated writes](https://github.com/tetherto/mdk/blob/main/docs/concepts/control-plane.md#approval-gated-writes) details the write action lifecycle's full cross-layer flow, and use [the write-actions how-to](/guides/gateway/write-actions) to submit and approve actions from a Gateway consumer - [How MDK works](/concepts/architecture): for the architectural narrative explaining when each action fires - See the [Kernel MDK Protocol spec](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#mdk-protocol) for every action, direction, and purpose - [Kernel modules](/reference/kernel/modules): the per-module specs that route and execute these actions - [Build a Worker](/guides/workers/build-a-worker): implement the Worker side of this protocol # Supported apps (/reference/supported-apps) ## Overview A Gateway serves exactly the apps your stack declares in `spec.apps[]`, and nothing else. Nothing here auto-registers: an app contributes its routes only when a stack names it. MDK ships the apps under `backend/apps/`, each offered through [`mdk onboard`](/guides/cli/install#command-groups) and declared by its package name (`@tetherto/mdk-app-`). The paths below are the ones in each app's route table. The Gateway serves them under `/apps/` unless the app's `spec.apps` entry sets `mount`; `mount: /` serves them as written. The `agent` app must be mounted at `/`, because its UI calls `/agent/sessions…` at the Gateway root. ## Route tables ### Optional apps #### `agent` | Method | Path | Description | | --- | --- | --- | | `POST` | `/agent/sessions` | Opens an agent session bound to the caller's identity | | `POST` | `/agent/sessions/:id/messages` | Streams an agent turn over SSE; write tools pause for an operator's approval | | `POST` | `/agent/sessions/:id/approvals/:approvalId` | Approves or rejects a paused write, resuming or ending the turn | | `DELETE` | `/agent/sessions/:id` | Deletes a session owned by the caller | #### `demo` | Method | Path | Description | | --- | --- | --- | | `GET` | `/api/demo/summary` | Fans out metrics telemetry to every registered device whose Worker exposes `hashrate_rt` in TH/s (what `mdk create worker` scaffolds) and returns fleet totals plus a per-device breakdown. Aggregation lives here — workers only ever answer for one device | | `GET` | `/api/demo/history` | Pulls the `history` telemetry channel (recent samples) for every matched device that declares one, or a single device when `deviceId` is set. Devices without the channel are listed with `hasHistory: false` and no samples | ## Next steps - [Declare an app in your stack](/guides/gateway/apps): the manifests, controllers, and how a stack runs them # Supported hardware (/reference/supported-hardware) ## Manufacturer-maintained workers ### Miners } title="Antminer" href="https://github.com/bitmain-official/antminer-mdk-worker" description="bitmain · Not enumerated; see manufacturer documentation" /> } title="Whatsminer" href="https://github.com/whatsminer/whatsminer-mdk-worker" description="microbt · Not enumerated; see manufacturer documentation" /> ### Mining pools } title="Foundry USA Pool" href="https://github.com/foundryservices/foundry-mdk-worker" description="foundry · FOUNDRY-BTC" /> ## MDK-maintained workers ### Miners } title="Avalon" href="https://github.com/tetherto/mdk/blob/main/backend/workers/miners/avalon/README.md" description="canaan · A1346" /> ### Containers } title="Antspace" href="https://github.com/tetherto/mdk/blob/main/backend/workers/containers/antspace/README.md" description="bitmain · HK3, IMM" /> } title="Bitdeer" href="https://github.com/tetherto/mdk/blob/main/backend/workers/containers/bitdeer/README.md" description="bitdeer · D40-A1346, D40-M30, D40-M56, D40-S19XP" /> ### Power meters } title="ABB" href="https://github.com/tetherto/mdk/blob/main/backend/workers/power-meter/abb/README.md" description="abb · B23, B24, M1M20, M4M20, REU615" /> } title="SATEC" href="https://github.com/tetherto/mdk/blob/main/backend/workers/power-meter/satec/README.md" description="satec · PM180" /> } title="Schneider Electric" href="https://github.com/tetherto/mdk/blob/main/backend/workers/power-meter/schneider/README.md" description="schneider · P3U30, PM5340" /> ### Sensors } title="Seneca" href="https://github.com/tetherto/mdk/blob/main/backend/workers/temperature/seneca/README.md" description="seneca · Z-4RTD-2" /> ### Mining pools } title="F2Pool" href="https://github.com/tetherto/mdk/blob/main/backend/workers/minerpools/f2pool/README.md" description="f2pool · F2POOL-BTC" /> } title="Ocean" href="https://github.com/tetherto/mdk/blob/main/backend/workers/minerpools/ocean/README.md" description="ocean · OCEAN-BTC" /> ## Contract conformance All worker contracts validate cleanly against the vendored schema. # UI Reference (/reference/ui) Complete API reference for the MDK UI packages: `@tetherto/mdk-react-devkit`, `@tetherto/mdk-react-adapter`, and `@tetherto/mdk-ui-foundation`. ## Quick links | Section | Description | Count | |---------|-------------|-------| | [Components](/reference/ui/components) | React components for building UIs | 157 components | | [Hooks](/reference/ui/hooks) | React hooks for state and data | 32 hooks | | [Query Helpers](/reference/ui/query-helpers) | TanStack Query helpers for data fetching | 6 queryHelpers | | [Stores](/reference/ui/stores) | Zustand stores for state management | 4 stores | | [Types](/reference/ui/types) | TypeScript type definitions | 125 types | | [Utilities](/reference/ui/utilities) | Helper functions and formatters | 64 utilities | ## Package overview ### `@tetherto/mdk-react-devkit` The main UI component library. Provides: - Production-ready React components - Component-specific hooks - TypeScript types for all components ```tsx ``` ### `@tetherto/mdk-react-adapter` React bindings for the foundation layer. Provides: - Zustand store access hooks - Authentication hooks - Permission hooks - Data fetching hooks ```tsx ``` ### `@tetherto/mdk-ui-foundation` Framework-agnostic foundation layer. Provides: - Zustand stores - TanStack Query helpers - Utility functions - TypeScript types ```tsx ``` ## Get started 1. [Install the packages](/guides/ui/install) 2. Import styles: `import '@tetherto/mdk-react-devkit/styles.css'` 3. Browse components by category or search for specific APIs # Components (/reference/ui/components) The `@tetherto/mdk-react-devkit` package provides production-ready React components organized by category. ## Prerequisites - Install the packages ```bash npm install \ @tetherto/mdk-react-devkit \ @tetherto/mdk-react-adapter \ @tetherto/mdk-ui-foundation ``` ```bash # Clone the MDK UI monorepo (adjust the URL to your fork if needed) git clone https://github.com/tetherto/mdk.git cd mdk/ui # Install dependencies and build packages (npm workspaces) npm install npm run build ``` Then add the dependency to your app's `package.json`: ```json { "dependencies": { "@tetherto/mdk-react-devkit": "*", "@tetherto/mdk-react-adapter": "*", "@tetherto/mdk-ui-foundation": "*" } } ``` Run `npm install` from the `mdk/ui` workspace root after your app is under `apps/` so npm links workspace packages. - Import the core styles in your app's entry point: ```tsx ``` ## Browse by category | Category | Description | |----------|-------------| | [Actions](/reference/ui/components/actions) | Buttons, action triggers, and export controls | | [Auth](/reference/ui/components/auth) | Authentication and sign-in components | | [Branding](/reference/ui/components/branding) | Logos, wordmarks, and brand elements | | [Cards](/reference/ui/components/cards) | Card containers and card-based layouts | | [Charts](/reference/ui/components/charts) | Data visualization and chart components | | [Dashboard](/reference/ui/components/dashboard) | Dashboard layouts and containers | | [Dialogs](/reference/ui/components/dialogs) | Modal dialogs and confirmation prompts | | [Display](/reference/ui/components/display) | Data display and formatting components | | [Feedback](/reference/ui/components/feedback) | Alerts, toasts, and user feedback | | [Filters](/reference/ui/components/filters) | Filter controls and filter bars | | [Forms](/reference/ui/components/forms) | Form inputs, selects, and validation | | [Layout](/reference/ui/components/layout) | Page layouts, grids, and spacing | | [Media](/reference/ui/components/media) | Images, icons, and media display | | [Monitoring](/reference/ui/components/monitoring) | System monitoring and status displays | | [Navigation](/reference/ui/components/navigation) | Sidebars, tabs, and navigation menus | | [Overlays](/reference/ui/components/overlays) | Popovers, tooltips, and overlay panels | | [Pages](/reference/ui/components/pages) | Full-page layouts and page shells | | [Settings](/reference/ui/components/settings) | Settings panels and preference controls | | [Tables](/reference/ui/components/tables) | Data tables and table utilities | | [Widgets](/reference/ui/components/widgets) | Dashboard widgets and data cards | ## Import pattern Components are imported from the package root: ```tsx ``` ## Styling Components use BEM-style CSS classes (e.g., `.mdk-button`, `.mdk-card__header`) for styling consistency. Every component forwards `className` to its root element. # Action (/reference/ui/components/actions) Components for triggering actions and user interactions. ## Prerequisites - Install the packages ```bash npm install \ @tetherto/mdk-react-devkit \ @tetherto/mdk-react-adapter \ @tetherto/mdk-ui-foundation ``` ```bash # Clone the MDK UI monorepo (adjust the URL to your fork if needed) git clone https://github.com/tetherto/mdk.git cd mdk/ui # Install dependencies and build packages (npm workspaces) npm install npm run build ``` Then add the dependency to your app's `package.json`: ```json { "dependencies": { "@tetherto/mdk-react-devkit": "*", "@tetherto/mdk-react-adapter": "*", "@tetherto/mdk-ui-foundation": "*" } } ``` Run `npm install` from the `mdk/ui` workspace root after your app is under `apps/` so npm links workspace packages. - Import styles: `import '@tetherto/mdk-react-devkit/styles.css'` ## Components @tetherto/mdk-react-devkit ### Button ```tsx ``` Primary action button with variants, sizes, loading state, icon placement, and full-width layout. Forwards refs and all native ` ) ``` @tetherto/mdk-react-devkit Import the public APIs on this page from [`@tetherto/mdk-react-devkit`](/reference/ui/#tethertomdk-react-devkit). ### `ActionButton` [**`ActionButton`**](/reference/ui/components/actions/#actionbutton) component with confirmation popover or dialog `agent-ready`
Source [src/primitives/components/action-button/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/action-button/USAGE.md) [src/primitives/components/action-button/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/action-button/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `confirmation` | Required | `ActionButtonConfirmation` | - | Configuration for the confirmation UI | | `className` | Optional | `string` | - | Additional class for the trigger button | | `disabled` | Optional | `boolean` | - | Disables the trigger button | | `label` | Optional | `string` | - | [**`Button`**](/reference/ui/components/actions/#button) label text | | `loading` | Optional | `boolean` | - | Shows a spinner on the trigger button | | `mode` | Optional | `"dialog" \| "popover"` | `"popover"` | Confirmation mode: popover (inline) or dialog (modal) | | `variant` | Optional | `"primary" \| "danger" \| "secondary"` | `"secondary"` | Visual style of the trigger button | ##### `ActionButtonConfirmation` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `title` | Required | `string` | - | Heading shown in the confirmation UI | | `cancelLabel` | Optional | `string` | `"Cancel"` | [**`Label`**](/reference/ui/components/forms/#label) for the cancel button | | `confirmLabel` | Optional | `string` | `'OK' (popover) / 'Confirm' (dialog)` | [**`Label`**](/reference/ui/components/forms/#label) for the confirm button. Defaults to `'OK'` in popover mode and `'Confirm'` in dialog mode | | `description` | Optional | `React.ReactNode` | - | Body text or node shown below the title | | `icon` | Optional | `React.ReactNode` | `` | Icon shown in the popover header (popover mode only) | | `onCancel` | Optional | `VoidFunction` | - | Fired when the user cancels | | `onConfirm` | Optional | `VoidFunction` | - | Fired when the user confirms | ### `Button` Primary action button. Supports loading state with spinner, icon placement, variants, sizes, and full-width layout. Forwards refs and all native button attributes `agent-ready`
Source [src/primitives/components/button/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/button/USAGE.md) [src/primitives/components/button/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/button/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `contentClassName` | Optional | `string` | - | Class names applied to the inner content wrapper | | `disabled` | Optional | `boolean` | `false` | Disable the button | | `fullWidth` | Optional | `boolean` | `false` | Make the button stretch to fill its container | | `icon` | Optional | `React.ReactNode` | - | Icon node rendered alongside `children` | | `iconPosition` | Optional | `"left" \| "right"` | `"left"` | Icon placement relative to children | | `loading` | Optional | `boolean` | `false` | Show a spinner instead of the content and disable the button | | `size` | Optional | `"sm" \| "md" \| "lg"` | - | Size token (`sm`, `md`, `lg`) | | `type` | Optional | `"button" \| "submit" \| "reset"` | `"button"` | Native button type | | `variant` | Optional | `"icon" \| "link" \| "primary" \| "danger" \| "secondary" \| "tertiary" \| "nav-link" \| "outline" \| "ghost"` | `"secondary"` | Visual variant (e.g. `primary`, `secondary`, `ghost`) | # Auth (/reference/ui/components/auth) Components for authentication flows and user sign-in. ## Prerequisites - Install the packages ```bash npm install \ @tetherto/mdk-react-devkit \ @tetherto/mdk-react-adapter \ @tetherto/mdk-ui-foundation ``` ```bash # Clone the MDK UI monorepo (adjust the URL to your fork if needed) git clone https://github.com/tetherto/mdk.git cd mdk/ui # Install dependencies and build packages (npm workspaces) npm install npm run build ``` Then add the dependency to your app's `package.json`: ```json { "dependencies": { "@tetherto/mdk-react-devkit": "*", "@tetherto/mdk-react-adapter": "*", "@tetherto/mdk-ui-foundation": "*" } } ``` Run `npm install` from the `mdk/ui` workspace root after your app is under `apps/` so npm links workspace packages. - Import styles: `import '@tetherto/mdk-react-devkit/styles.css'` ## Components @tetherto/mdk-react-devkit Import the public APIs on this page from [`@tetherto/mdk-react-devkit`](/reference/ui/#tethertomdk-react-devkit). ### `RequireAuth` Route guard that reads the session token from the headless `authStore` (via `useAuth`) and renders the children only when a token is present. Otherwise it renders `fallback` — typically `` from `react-router`. Router-agnostic by design `agent-ready`
Source [src/domain/components/auth/require-auth/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/auth/require-auth/USAGE.md) [src/domain/components/auth/require-auth/require-auth.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/auth/require-auth/require-auth.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `children` | Required | `React.ReactNode` | - | Rendered when a token is present | | `fallback` | Required | `React.ReactNode` | - | Rendered when no token is present — typically `` | | `rememberPath` | Optional | `boolean` | `true` | When true (default), the current location is persisted to sessionStorage before rendering the fallback so the sign-in flow can return there | ### `SignInGoogleButton` One-click Google OAuth sign-in trigger. Defaults to a full-page redirect to `${oauthBaseUrl}/oauth/google`, the Gateway's Google sign-in route `agent-ready`
Source [src/domain/components/auth/sign-in-google-button/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/auth/sign-in-google-button/USAGE.md#props-detail) [src/domain/components/auth/sign-in-google-button/sign-in-google-button.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/auth/sign-in-google-button/sign-in-google-button.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `oauthBaseUrl` | Required | `string` | - | Base URL of the OAuth backend (no trailing slash). Click navigates to `${oauthBaseUrl}/oauth/google` | | `contentClassName` | Optional | `string` | - | Class names applied to the inner content wrapper | | `disabled` | Optional | `boolean` | `false` | Disable the button | | `fullWidth` | Optional | `boolean` | `false` | Make the button stretch to fill its container | | `icon` | Optional | `React.ReactNode` | - | Icon node rendered alongside `children` | | `iconPosition` | Optional | `"left" \| "right"` | `"left"` | Icon placement relative to children | | `label` | Optional | `string` | `"Sign in with Google"` | Override the visible button label | | `loading` | Optional | `boolean` | `false` | Show a spinner instead of the content and disable the button | | `onClick` | Optional | `(() => void)` | `redirect` | Override the click behaviour entirely. When set, `oauthBaseUrl` is ignored | | `size` | Optional | `"sm" \| "md" \| "lg"` | - | Size token (`sm`, `md`, `lg`) | | `type` | Optional | `"button" \| "submit" \| "reset"` | `"button"` | Native button type | | `variant` | Optional | `"icon" \| "link" \| "primary" \| "danger" \| "secondary" \| "tertiary" \| "nav-link" \| "outline" \| "ghost"` | `"secondary"` | Visual variant (e.g. `primary`, `secondary`, `ghost`) | # Branding (/reference/ui/components/branding) Components for brand identity and visual consistency. ## Prerequisites - Install the packages ```bash npm install \ @tetherto/mdk-react-devkit \ @tetherto/mdk-react-adapter \ @tetherto/mdk-ui-foundation ``` ```bash # Clone the MDK UI monorepo (adjust the URL to your fork if needed) git clone https://github.com/tetherto/mdk.git cd mdk/ui # Install dependencies and build packages (npm workspaces) npm install npm run build ``` Then add the dependency to your app's `package.json`: ```json { "dependencies": { "@tetherto/mdk-react-devkit": "*", "@tetherto/mdk-react-adapter": "*", "@tetherto/mdk-ui-foundation": "*" } } ``` Run `npm install` from the `mdk/ui` workspace root after your app is under `apps/` so npm links workspace packages. - Import styles: `import '@tetherto/mdk-react-devkit/styles.css'` ## Components @tetherto/mdk-react-devkit Import the public APIs on this page from [`@tetherto/mdk-react-devkit`](/reference/ui/#tethertomdk-react-devkit). ### `MdkWordmark` MDK wordmark — the canonical brand lockup, rendered as inline SVG so it tints to `currentColor`. Use this in [``](/reference/ui/components/navigation/#appheader) (via the `logo` slot) or anywhere else the brand should appear `agent-ready`
Source [src/primitives/components/mdk-wordmark/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/mdk-wordmark/USAGE.md) [src/primitives/components/mdk-wordmark/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/mdk-wordmark/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `className` | Optional | `string` | - | Optional class hook on the outer `` | | `size` | Optional | `"sm" \| "md" \| "lg"` | `"md"` | Visual size of the wordmark. `sm` ≈ 24px tall, `md` ≈ 32px, `lg` ≈ 64px | | `title` | Optional | `string` | `"MDK"` | Accessible label | # Card (/reference/ui/components/cards) Card components for grouping and presenting content. ## Prerequisites - Install the packages ```bash npm install \ @tetherto/mdk-react-devkit \ @tetherto/mdk-react-adapter \ @tetherto/mdk-ui-foundation ``` ```bash # Clone the MDK UI monorepo (adjust the URL to your fork if needed) git clone https://github.com/tetherto/mdk.git cd mdk/ui # Install dependencies and build packages (npm workspaces) npm install npm run build ``` Then add the dependency to your app's `package.json`: ```json { "dependencies": { "@tetherto/mdk-react-devkit": "*", "@tetherto/mdk-react-adapter": "*", "@tetherto/mdk-ui-foundation": "*" } } ``` Run `npm install` from the `mdk/ui` workspace root after your app is under `apps/` so npm links workspace packages. - Import styles: `import '@tetherto/mdk-react-devkit/styles.css'` ## Components @tetherto/mdk-react-devkit Import the public APIs on this page from [`@tetherto/mdk-react-devkit`](/reference/ui/#tethertomdk-react-devkit). ### `ActiveIncidentsCard` Summary card displaying a list of active incidents/alerts with severity indicators, loading skeleton, and empty state. Rows are virtualized via `@tanstack/react-virtual` so the card stays responsive with thousands of incidents `agent-ready`
Source [src/domain/components/active-incidents-card/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/active-incidents-card/USAGE.md) [src/domain/components/active-incidents-card/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/active-incidents-card/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `className` | Optional | `string` | - | Additional class names appended to the root | | `emptyMessage` | Optional | `string` | - | Message rendered when no items | | `isLoading` | Optional | `boolean` | `false` | Show skeleton rows instead of items | | `items` | Optional | `TIncidentRowProps[]` | `[]` | Incident rows to render | | `label` | Optional | `string` | `"Active Alerts"` | Header label shown above the list | | `onItemClick` | Optional | `(id: string) => void` | - | Called with the incident id when a row is clicked | | `skeletonRows` | Optional | `number` | `4` | Number of skeleton rows shown when `isLoading` | ##### `TIncidentRowProps` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `body` | Required | `string` | - | Third line of the row, under `subtitle` | | `id` | Required | `string` | - | Incident identifier; the virtual-list row key and the argument passed to the click handler | | `severity` | Required | `"high" \| "medium" \| "critical"` | - | `critical`, `high` or `medium`; sets the colored severity dot beside the row | | `subtitle` | Required | `string` | - | Second line of the row, under `title` | | `title` | Required | `string` | - | First line of the row | | `onClick` | Optional | `((id: string) => void)` | - | Row click handler, called with `id`; in [`ActiveIncidentsCard`](/reference/ui/components/cards/#activeincidentscard) it is always replaced by `onItemClick` | ### `MetricCard` Compact card displaying a labelled metric value with optional highlight and transparency states `agent-ready`
Source [src/domain/components/composite/metric-card/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/composite/metric-card/USAGE.md) [src/domain/components/composite/metric-card/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/composite/metric-card/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `label` | Required | `string` | - | Text label shown above the value | | `unit` | Required | `string` | - | Unit suffix appended after the value (e.g. `"TH/s"`, `"W"`, `"USD"`) | | `value` | Required | `string \| number \| null` | - | Metric value to display | | `bgColor` | Optional | `string` | `BLACK_ALPHA_05` | Custom background color (CSS color string) | | `className` | Optional | `string` | - | Additional class names appended to the root element | | `isHighlighted` | Optional | `boolean` | `false` | Renders the value in orange to draw attention | | `isTransparentColor` | Optional | `boolean` | `false` | Renders the value in a low-opacity white for de-emphasized display | | `isValueMedium` | Optional | `boolean` | `false` | Applies a medium-weight variant to the value typography | | `noMinWidth` | Optional | `boolean` | `false` | Removes the default minimum width so the card shrinks to content | | `showDashForZero` | Optional | `boolean` | `false` | Displays `—` instead of `0` when value is zero | ### `MiningPoolsPanel` Dashboard card that lists configured mining pools — one row per pool, with revenue, hash rate, and an optional "Show details" action. Pure presentation: the row data + click handler come from props, shaped upstream by the caller into `MiningPoolRow`s `agent-ready`
Source [src/domain/components/mining-pools-panel/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/mining-pools-panel/USAGE.md) [src/domain/components/mining-pools-panel/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/mining-pools-panel/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `className` | Optional | `string` | - | Extra className for the root | | `emptyMessage` | Optional | `string` | `"No pools configured"` | Message shown when `rows` is empty | | `hideHeader` | Optional | `boolean` | `false` | Hide the title row entirely | | `isLoading` | Optional | `boolean` | `false` | Loading state — renders skeleton rows | | `label` | Optional | `string` | `"Mining Pools"` | Override the card title — defaults to `Mining Pools` | | `onShowDetails` | Optional | `(row: MiningPoolRow) => void` | - | Called when the user clicks the per-row "Show details" button | | `rows` | Optional | `MiningPoolRow[]` | `[]` | Pool rows, in display order | | `skeletonRows` | Optional | `number` | `3` | Number of skeleton rows to show while loading | ##### `MiningPoolRow` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `id` | Required | `string` | - | Stable React key | | `name` | Required | `string` | - | Display name (e.g. `minerpool-f2pool-shelf-0`) | | `details` | Optional | `PoolDetailItem[]` | - | Key/value details rendered inside the "Show details" popover | | `hashratePhs` | Optional | `number` | - | Hashrate in PH/s — used for display (TH/s shown automatically below 1 PH/s) | | `revenue24hBtc` | Optional | `number` | - | 24h revenue in BTC; rendered as `0.00 BTC` when undefined | ### `PoolDetailsCard` Compact key/value card for displaying pool metadata (URL, fee, worker count, etc.). Empty list renders a "No data available" placeholder `agent-ready`
Source [src/domain/components/pool-details-card/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/pool-details-card/USAGE.md) [src/domain/components/pool-details-card/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/pool-details-card/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `details` | Required | `PoolDetailItem[]` | - | Detail rows to render | | `className` | Optional | `string` | - | Additional class names | | `label` | Optional | `string` | - | Header label | | `underline` | Optional | `boolean` | `false` | Render an underline under the label | ##### `PoolDetailItem` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `title` | Required | `string` | - | Row label, rendered with a trailing colon | | `value` | Optional | `string \| number` | - | Row value; renders `-` when undefined or an empty string | ### `PoolDetailsPopover` [**`Button`**](/reference/ui/components/actions/#button)-triggered popover that displays a pool's key/value details (URL, fee, worker count, status, …) inside a Radix `Dialog`. Wraps [`PoolDetailsCard`](/reference/ui/components/cards/#pooldetailscard) so the read-out matches the embedded card variant `agent-ready`
Source [src/domain/components/pool-details-popover/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/pool-details-popover/USAGE.md) [src/domain/components/pool-details-popover/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/pool-details-popover/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `details` | Required | `PoolDetailItem[]` | - | Detail rows | | `className` | Optional | `string` | - | Additional class names | | `description` | Optional | `string` | - | Dialog body description | | `disabled` | Optional | `boolean` | `false` | Disable the trigger | | `title` | Optional | `string` | - | Dialog title | | `triggerLabel` | Optional | `string` | - | Trigger button label | # Chart (/reference/ui/components/charts) Components for data visualization and charting. ## Prerequisites - Install the packages ```bash npm install \ @tetherto/mdk-react-devkit \ @tetherto/mdk-react-adapter \ @tetherto/mdk-ui-foundation ``` ```bash # Clone the MDK UI monorepo (adjust the URL to your fork if needed) git clone https://github.com/tetherto/mdk.git cd mdk/ui # Install dependencies and build packages (npm workspaces) npm install npm run build ``` Then add the dependency to your app's `package.json`: ```json { "dependencies": { "@tetherto/mdk-react-devkit": "*", "@tetherto/mdk-react-adapter": "*", "@tetherto/mdk-ui-foundation": "*" } } ``` Run `npm install` from the `mdk/ui` workspace root after your app is under `apps/` so npm links workspace packages. - Import styles: `import '@tetherto/mdk-react-devkit/styles.css'` ## Components @tetherto/mdk-react-devkit ### Chart container ```tsx ``` A layout wrapper for charts that provides a title/header row, interactive legend, range selector, highlighted value display, loading/empty states, and a stats footer. #### Notes - `minMaxAvg` and `timeRange` are only rendered when the chart is not loading or empty #### Example [#chart-container-example] ```tsx /** * Runnable example for ChartContainer. */ const RANGE_OPTIONS = [ { label: '1H', value: '1h' }, { label: '24H', value: '24h' }, { label: '7D', value: '7d' }, ] const LEGEND_DATA = [ { label: 'Pool A', color: '#59E8E8' }, { label: 'Pool B', color: '#FF9500' }, ] const MockChart = () => (
Chart content
) const [range, setRange] = useState('24h') return (
) } ``` #### Related API - [**`useChartDataCheck`**](/reference/ui/hooks/charts/#usechartdatacheck) @tetherto/mdk-react-devkit Import the public APIs on this page from [`@tetherto/mdk-react-devkit`](/reference/ui/#tethertomdk-react-devkit). ### `AreaChart` Presentational Chart.js area chart (Line with fill). Data must be provided via props; this component does no fetching of its own `agent-ready`
Source [src/primitives/components/area-chart/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/area-chart/USAGE.md) [src/primitives/components/area-chart/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/area-chart/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `data` | Required | `ChartData<"line", (number \| Point \| null)[], unknown>` | - | Chart data - required, provided by parent | | `className` | Optional | `string` | - | Additional class names | | `height` | Optional | `number` | `300` | Chart height in pixels | | `options` | Optional | `object` | - | Chart.js options - merged with defaults | | `tooltip` | Optional | `ChartTooltipConfig` | - | Custom HTML tooltip configuration. When provided, replaces the default Chart.js tooltip | ### `AverageDowntimeChart` Stacked bar chart of average downtime (curtailment vs operational issues). Wraps [`ChartContainer`](/reference/ui/components/charts/#chartcontainer) and [`BarChart`](/reference/ui/components/charts/#barchart); pass period labels and rate arrays via `data` `agent-ready`
Source [src/primitives/components/average-downtime-chart/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/average-downtime-chart/USAGE.md) [src/primitives/components/average-downtime-chart/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/average-downtime-chart/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `barWidth` | Optional | `number` | `38` | Max bar thickness | | `className` | Optional | `string` | - | Extra class on the container | | `data` | Optional | `AverageDowntimeChartData` | - | Period labels and rate arrays (fractions 0–1) | | `emptyMessage` | Optional | `string` | - | Message when there are no period labels or rate series | | `height` | Optional | `number` | `280` | Chart height in pixels | | `isLoading` | Optional | `boolean` | `false` | Shows loading overlay | | `showDataLabels` | Optional | `boolean` | `false` | Show values above stacked bars | | `title` | Optional | `string` | `"Monthly Average Downtime"` | Chart title (unit renders on its own line below) | | `unit` | Optional | `string` | `%` | Unit subtitle under the title | | `yTicksFormatter` | Optional | `(value: number) => string` | - | Formats Y-axis ticks, tooltips, and bar data labels (values are 0–1 rates). Defaults to rate × 100 via `formatNumber` | ##### `AverageDowntimeChartData` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `labels` | Required | `string[]` | - | X-axis period labels, one per bar; empty renders the empty state | | `curtailment` | Optional | `number[]` | - | "Curtailment" downtime rate per period, as a fraction (0–1), aligned to `labels` | | `operationalIssues` | Optional | `number[]` | - | "Op. Issues" downtime rate per period, as a fraction (0–1), stacked on `curtailment` | ### `BarChart` Presentational Chart.js bar chart. Data must be pre-aggregated; use grouped or stacked categories via `datasets` `agent-ready`
Source [src/primitives/components/bar-chart/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/bar-chart/USAGE.md) [src/primitives/components/bar-chart/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/bar-chart/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `data` | Required | `any` | - | Chart data - required, provided by parent. Use `as any` for mixed bar+line datasets | | `className` | Optional | `string` | - | Additional class for the wrapper `div` | | `formatDataLabel` | Optional | `((value: number) => string)` | - | Format data label values (default: round to nearest integer) | | `formatYLabel` | Optional | `((value: number) => string)` | - | Format Y-axis tick labels | | `height` | Optional | `number` | `300` | Chart height in pixels | | `isHorizontal` | Optional | `boolean` | `false` | Render bars horizontally (indexAxis: 'y') | | `isStacked` | Optional | `boolean` | `false` | Stack bars on top of each other | | `legendAlign` | Optional | `"center" \| "start" \| "end"` | `"start"` | Alignment of the legend labels within their position | | `legendPosition` | Optional | `"left" \| "right" \| "top" \| "bottom"` | `"top"` | Position of the legend | | `options` | Optional | `object` | - | Chart.js options - merged with defaults | | `showDataLabels` | Optional | `boolean` | `false` | Show values above each bar | | `showLegend` | Optional | `boolean` | `true` | Show built-in Chart.js legend | | `tooltip` | Optional | `ChartTooltipConfig` | - | Custom HTML tooltip configuration. When provided, replaces the default Chart.js tooltip | ### `ChartContainer` Standard chrome for charts: title, optional highlighted value, legend with toggle, range selector (radio cards), loading / empty states, and a footer for min/max/avg stats `agent-ready`
Source [src/primitives/components/chart-container/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/chart-container/USAGE.md) [src/primitives/components/chart-container/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/chart-container/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `children` | Required | `React.ReactNode` | - | The chart element to render | | `className` | Optional | `string` | - | Additional class for the root element | | `empty` | Optional | `boolean` | - | Hides the chart and shows `emptyMessage` | | `emptyMessage` | Optional | `React.ReactNode` | `"No data available"` | Message shown when `empty` is true: a string, or any element (an [`EmptyState`](/reference/ui/components/feedback/#emptystate), say) | | `footer` | Optional | `React.ReactNode` | - | Custom footer content rendered below the chart | | `footerClassName` | Optional | `string` | - | Additional class for the footer area | | `header` | Optional | `React.ReactNode` | - | Replaces the default `title` heading with a custom element | | `headerAction` | Optional | `React.ReactNode` | - | Optional action rendered on the right side of the header row (e.g. an expand/fullscreen toggle). Sits alongside the range selector when both are present. Purely additive - omit it and the header renders exactly as before | | `highlightedValue` | Optional | `HighlightedValueProps` | - | Large value/unit displayed alongside the legend | | `legendData` | Optional | `LegendItem[]` | - | Color-keyed legend items; each item can be toggled | | `loader` | Optional | `React.ReactNode` | - | Replaces the default [``](/reference/ui/components/feedback/#loader) in the loading overlay | | `loading` | Optional | `boolean` | - | Shows a centered [``](/reference/ui/components/feedback/#loader) overlay | | `minMaxAvg` | Optional | `Partial<{ min: string; max: string; avg: string }>` | - | Built-in footer showing Min / Avg / Max values | | `onToggleDataset` | Optional | `((index: number) => void)` | - | Fired when a legend item is clicked | | `rangeSelector` | Optional | `RangeSelectorProps` | - | [**`Radio`**](/reference/ui/components/forms/#radio)-card time-range selector | | `timeRange` | Optional | `string` | - | Time range label shown in the footer | | `title` | Optional | `string` | - | Chart heading (renders as `

` unless `header` is provided) | | `titleExtra` | Optional | `React.ReactNode` | - | Optional node rendered immediately after the title text (e.g. an info tooltip). Only shown when `title` is set and `header` is not. Additive - omit it and the title renders exactly as before | ##### `LegendItem` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `color` | Required | `string` | - | Color string (hex, hsl, etc.) | | `label` | Required | `string` | - | Legend label | | `hidden` | Optional | `boolean` | - | Whether this dataset is currently hidden | ##### `HighlightedValueProps` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `value` | Required | `string \| number` | - | The primary value to display | | `className` | Optional | `string` | - | Additional class | | `style` | Optional | `React.CSSProperties` | - | Inline style | | `unit` | Optional | `string` | - | Unit suffix | ##### `RangeSelectorProps` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `onChange` | Required | `(value: string) => void` | - | Fires when user picks a range | | `options` | Required | `RangeSelectorOption[]` | - | `{ label, value }` items | | `value` | Required | `string` | - | Currently selected value | | `buttonClassName` | Optional | `string` | - | Additional class for each option button | | `className` | Optional | `string` | - | Additional class for the selector | | `style` | Optional | `React.CSSProperties` | - | Inline style for the selector | ### `ChartStatsFooter` [**`ChartStatsFooter`**](/reference/ui/components/charts/#chartstatsfooter) - Displays Min/Max/Avg values and optional stats grid below a chart `agent-ready`
Source [src/primitives/components/chart-stats-footer/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/chart-stats-footer/USAGE.md) [src/primitives/components/chart-stats-footer/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/chart-stats-footer/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `className` | Optional | `string` | - | Custom class name | | `minMaxAvg` | Optional | `Partial<{ min: string; max: string; avg: string }>` | - | Min/Max/Avg values row | | `secondaryLabel` | Optional | `SecondaryLabel` | - | Secondary label displayed below stats | | `stats` | Optional | `ChartStatsFooterItem[]` | - | Additional stats displayed in a columnar grid | | `statsPerColumn` | Optional | `number` | `1` | Number of stat items per column (default: 1) | ##### `ChartStatsFooterItem` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `label` | Required | `string` | - | Stat label | | `value` | Required | `string \| number` | - | Stat value | ##### `SecondaryLabel` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `title` | Required | `string` | - | [**`Label`**](/reference/ui/components/forms/#label) text | | `value` | Required | `string \| number` | - | Value text | ### `DetailLegend` [**`DetailLegend`**](/reference/ui/components/charts/#detaillegend) - Enhanced chart legend with current values and percentage change indicators `agent-ready`
Source [src/primitives/components/detail-legend/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/detail-legend/USAGE.md) [src/primitives/components/detail-legend/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/detail-legend/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `items` | Required | `DetailLegendItem[]` | - | Legend items to display | | `className` | Optional | `string` | - | Custom class name | | `onToggle` | Optional | `((label: string, index: number) => void)` | - | Callback when a legend item is toggled | ##### `DetailLegendItem` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `color` | Required | `string` | - | Hex color for the default swatch (border, plus 20% opacity fill) and the `color` of a custom `icon` | | `label` | Required | `string` | - | Display label; also passed to `onToggle` | | `currentValue` | Optional | `{ value: string \| number; unit?: string }` | - | Value shown below the label, numbers formatted, with optional `unit`; also the row that holds `percentChange` | | `hidden` | Optional | `boolean` | - | Dims the item (30% opacity) to show its dataset is hidden | | `icon` | Optional | `React.ReactNode` | - | Custom icon replacing the default color swatch | | `percentChange` | Optional | `number \| null` | - | Percentage change shown after `currentValue`: positive as a green ▲, negative as a red ▼; hidden when `0`, `null` or without `currentValue` | ### `DoughnutChart` [**`DoughnutChart`**](/reference/ui/components/charts/#doughnutchart) – Presentational Chart.js doughnut chart with custom HTML legend matching the MDK design `agent-ready`
Source [src/primitives/components/doughnut-chart/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/doughnut-chart/USAGE.md) [src/primitives/components/doughnut-chart/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/doughnut-chart/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `data` | Required | `DoughnutChartDataset[]` | - | Array of labelled slices | | `borderWidth` | Optional | `number` | `4` | Border width between segments (default: 4) | | `className` | Optional | `string` | - | Additional class for the root element | | `cutout` | Optional | `string` | `"75%"` | Doughnut cutout percentage (default: '75%') | | `formatValue` | Optional | `((value: number) => string)` | - | Formats slice values in the built-in legend and default tooltip (default: raw number) | | `height` | Optional | `number` | `260` | Chart height in pixels | | `legendPosition` | Optional | `"left" \| "right" \| "top" \| "bottom"` | `"top"` | Where to place the legend relative to the chart (default: 'top') | | `options` | Optional | `object` | - | Chart.js options – merged with defaults | | `tooltip` | Optional | `ChartTooltipConfig` | - | Custom HTML tooltip configuration. When provided, replaces the default doughnut tooltip (which shows label, value with unit, and percentage). Use `valueFormatter` to replicate the percentage display if needed | | `unit` | Optional | `string` | `""` | Unit suffix appended to values in tooltips and legends | ##### `DoughnutChartDataset` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `label` | Required | `string` | - | Slice label | | `value` | Required | `number` | - | Numeric slice value | | `color` | Optional | `string` | - | Slice color (falls back to the built-in palette) | ### `GaugeChart` [**`GaugeChart`**](/reference/ui/components/charts/#gaugechart) - Presentational gauge / speedometer chart. Implementation note: the gauge is drawn by a pure-SVG internal implementation (see `./gauge-svg.tsx`), so the component is bundler- and runtime-agnostic and carries no third-party runtime dependency. The gauge packages on NPM are published CommonJS-only with a `module` field pointing at the same CJS file, which crashes under ESM bundlers that add no `__esModule ? .default : module` interop shim (Webpack 4, raw esbuild, some SSR setups) with React's "Element type is invalid" error `agent-ready`
Source [src/primitives/components/gauge-chart/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/gauge-chart/USAGE.md) [src/primitives/components/gauge-chart/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/gauge-chart/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `percent` | Required | `number` | - | Value between 0 and 1 (e.g. 0.75 = 75%). Values outside the range are clamped | | `arcsLength` | Optional | `number[]` | - | Custom arc-segment proportions (auto-normalised), overriding `nrOfLevels`. e.g. `[0.7, 0.3]` for a progress-style gauge whose first arc is the fill | | `arcWidth` | Optional | `number` | `0.2` | Arc thickness as a fraction of the gauge radius (0–1) | | `className` | Optional | `string` | - | Additional class for the root `div` | | `colors` | Optional | `string[]` | `[COLOR.GREEN, COLOR.RED]` | Arc colours in HEX format | | `formatTextValue` | Optional | `((percent: number) => string)` | - | Format the center label from the clamped fraction (0–1) | | `height` | Optional | `string \| number` | `200` | Chart height in pixels or any CSS length (e.g. `'200px'` or `'50%'`) | | `hideNeedle` | Optional | `boolean` | `false` | Hide the needle + hub (e.g. for a progress-style gauge) | | `hideText` | Optional | `boolean` | `false` | Hide the percentage text rendered inside the gauge | | `id` | Optional | `string` | `"mdk-gauge-chart"` | Stable id used for the gauge's accessibility labels | | `maxWidth` | Optional | `number` | - | Maximum width in pixels | | `needleColor` | Optional | `string` | `COLOR.STEEL_GRAY` | Needle + hub colour | | `nrOfLevels` | Optional | `number` | `3` | Number of arc segments. Ignored when `arcsLength` is provided | ### `Heatmap` [**`Heatmap`**](/reference/ui/components/charts/#heatmap) — a generic grid of value-coloured cells on a low→high gradient. Presentational and domain-agnostic: pass a row-major matrix of cells and an optional `[min, max]` range (auto-derived otherwise). Use `renderCell` to overlay domain content (socket borders, selection, tooltips) without forking the primitive — the grid still owns each cell's background colour. Pair with [`HeatmapLegend`](/reference/ui/components/charts/#heatmaplegend) for a gradient scale `agent-ready`
Source [src/primitives/components/heatmap/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/heatmap/USAGE.md) [src/primitives/components/heatmap/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/heatmap/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `data` | Required | `HeatmapCell[][]` | - | Rows of cells (row-major). Rows may be ragged | | `ariaLabel` | Optional | `string` | `"Heatmap"` | Accessible label for the grid | | `className` | Optional | `string` | - | Additional class for the root element | | `colors` | Optional | `readonly string[]` | `HEATMAP_GRADIENT` | Gradient stops low→high. Defaults to the cold→hot `HEATMAP_GRADIENT` | | `emptyColor` | Optional | `string` | `#000000` | Colour used for `null` cells | | `max` | Optional | `number` | `auto` | Range ceiling (maps to the last gradient stop); auto-derived from the finite values when omitted | | `min` | Optional | `number` | `auto` | Range floor (maps to the first gradient stop); auto-derived from the finite values when omitted | | `renderCell` | Optional | `((cell: HeatmapCell, context: HeatmapCellContext) => React.ReactNode)` | - | Override the cell's inner content — e.g. to overlay socket borders, selection, or tooltips for a PDU grid. The primitive still owns the cell's background colour (passed via `context.color`) | | `showValues` | Optional | `boolean` | `false` | Render each cell's value/label as text | ### `HeatmapLegend` [**`HeatmapLegend`**](/reference/ui/components/charts/#heatmaplegend) — a gradient bar with low/high scale labels, matching the gradient used by [`Heatmap`](/reference/ui/components/charts/#heatmap) `agent-ready`
Source [src/primitives/components/heatmap/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/heatmap/USAGE.md) [src/primitives/components/heatmap/heatmap-legend.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/heatmap/heatmap-legend.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `max` | Required | `string \| number` | - | Value (or pre-formatted label) at the high end of the scale | | `min` | Required | `string \| number` | - | Value (or pre-formatted label) at the low end of the scale | | `className` | Optional | `string` | - | Additional class for the root element | | `colors` | Optional | `readonly string[]` | `HEATMAP_GRADIENT` | Gradient stops low→high | | `label` | Optional | `string` | - | Heading above the gradient bar (e.g. "Temperature") | | `unit` | Optional | `string` | - | Unit suffix appended to `min`/`max` | ### `LineChart` Customisable Chart.js line chart with built-in zoom, tooltip, and legend. Data is passed via props; the component does no fetching `agent-ready`
Source [src/primitives/components/line-chart/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/line-chart/USAGE.md) [src/primitives/components/line-chart/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/line-chart/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `data` | Required | `LineChartData` | - | Data of the chart | | `backgroundColor` | Optional | `string` | - | Background color of the chart | | `beginAtZero` | Optional | `boolean` | - | Starts the value axis at 0 | | `chartRef` | Optional | `React.MutableRefObject` | - | Mutable ref to hold the LightWeightCharts reference | | `customDateFormat` | Optional | `string` | - | Custom date format | | `customLabel` | Optional | `string` | - | TODO: Doc | | `disableAutoRange` | Optional | `boolean` | - | Disable automatically determining range | | `fadedBackground` | Optional | `boolean` | - | Use a faded background | | `fixedTimezone` | Optional | `string` | - | Applies offset if provided, otherwise timestamps are assumed to already be in local time. Otherwise, use browser's current timezone offset for consistent time display | | `height` | Optional | `number` | `240` | Controls the height of the chart | | `horizontalLineLabelVisible` | Optional | `boolean` | - | Show horizontal line at mouse position | | `priceFormatter` | Optional | `((value: number) => string)` | - | Callback to format ticks on y axis | | `roundPrecision` | Optional | `number` | - | The number of decimals to show | | `shouldResetZoom` | Optional | `boolean` | - | Wether to Reset Zoom | | `showDateInTooltip` | Optional | `boolean` | - | Show date line in tooltip | | `showPointMarkers` | Optional | `boolean` | - | Show a marker on the line | | `skipMinWidth` | Optional | `boolean` | - | Do not enforce a min width | | `skipRound` | Optional | `boolean` | - | Prevent rounding of values | | `timeline` | Optional | `string` | - | TODO: DOC | | `uniformDistribution` | Optional | `boolean` | - | Changes horizontal scale marks generation. With this flag equal to true, marks of the same weight are either all drawn or none are drawn at all | | `unit` | Optional | `string` | `""` | The unit to display with values | | `verticalLineLabelVisible` | Optional | `boolean` | - | Show vertical line at mouse position | | `yTicksFormatter` | Optional | `((value: number) => string)` | - | Callback to format ticks on y axis. If `priceFormatter` is given. It would be used instead | ##### `LineChartData` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `datasets` | Required | `LineDataset[]` | - | One line series per entry; `visible: false` hides a series without removing it | ### `LineChartCard` Composable line-chart card with title, timeline range selector, legend (basic or detailed), error boundary, and an optional min/max/avg footer. Accepts either pre-shaped `data` or `rawData` + a `dataAdapter` callback so upstream domain components can keep their data wrangling local `agent-ready`
Source [src/domain/components/line-chart-card/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/line-chart-card/USAGE.md) [src/domain/components/line-chart-card/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/line-chart-card/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `chartProps` | Optional | `Partial` | - | Pass-through props to the core [**`LineChart`**](/reference/ui/components/charts/#linechart) | | `chartRef` | Optional | `React.MutableRefObject` | - | Ref to the lightweight-charts IChartApi | | `className` | Optional | `string` | - | Custom class name | | `data` | Optional | `LineChartCardData` | - | Pre-adapted chart data (use this OR rawData+dataAdapter) | | `dataAdapter` | Optional | `(data: unknown) => LineChartCardData` | - | Adapter to transform rawData into LineChartCardData | | `defaultTimeline` | Optional | `string` | `first option` | Default timeline when uncontrolled | | `detailLegends` | Optional | `boolean` | `false` | Show detail legends with current values | | `headerAction` | Optional | `React.ReactNode` | - | Optional action rendered on the right of the card header (e.g. an expand toggle). Passed straight through to [`ChartContainer`](/reference/ui/components/charts/#chartcontainer). Additive - omit it and the card header is unchanged | | `isLoading` | Optional | `boolean` | `false` | Loading state | | `minHeight` | Optional | `string \| number` | `350` | Minimum chart height | | `onTimelineChange` | Optional | `(timeline: string) => void` | - | Callback when timeline changes | | `rawData` | Optional | `unknown` | - | Raw data to be transformed by dataAdapter | | `shouldResetZoom` | Optional | `boolean` | `true` | Whether to reset zoom on timeline change (default: true) | | `timeline` | Optional | `string` | - | Controlled timeline value | | `timelineOptions` | Optional | `TimelineOption[]` | - | Timeline range selector options | | `title` | Optional | `string` | - | Chart title | | `titleExtra` | Optional | `React.ReactNode` | - | Optional node rendered next to the title (e.g. an info tooltip). Passed straight through to [`ChartContainer`](/reference/ui/components/charts/#chartcontainer). Additive | ##### `TimelineOption` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `label` | Required | `string` | - | Range-selector button text | | `value` | Required | `string` | - | Timeline key (e.g. `5m`, `1h`) passed to `onTimelineChange` and matched against `timeline` | | `disabled` | Optional | `boolean` | - | Not forwarded to the range selector; currently has no effect | ### `MinMaxAvg` Min / Max / Avg summary row with consistent MDK label and value styling `agent-ready`
Source [src/primitives/components/min-max-avg/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/min-max-avg/USAGE.md) [src/primitives/components/min-max-avg/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/min-max-avg/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `avg` | Optional | `string` | - | Average value (hidden if empty) | | `className` | Optional | `string` | - | Additional root class | | `max` | Optional | `string` | - | Maximum value (hidden if empty) | | `min` | Optional | `string` | - | Minimum value (hidden if empty) | ### `OperationsEnergyCostChart` Doughnut breakdown of Operations vs Energy cost (USD per MWh). Wraps [`ChartContainer`](/reference/ui/components/charts/#chartcontainer) and [`DoughnutChart`](/reference/ui/components/charts/#doughnutchart); pass `operationalCostsUSD` and `energyCostsUSD` via `data` `agent-ready`
Source [src/primitives/components/operations-energy-cost-chart/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/operations-energy-cost-chart/USAGE.md) [src/primitives/components/operations-energy-cost-chart/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/operations-energy-cost-chart/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `className` | Optional | `string` | - | Extra class on the container | | `data` | Optional | `Partial<{ energyCostsUSD: number; operationalCostsUSD: number }>` | - | `operationalCostsUSD` and `energyCostsUSD` | | `emptyMessage` | Optional | `string` | - | Message when both costs are zero or missing | | `height` | Optional | `number` | `200` | Doughnut height in pixels | | `isLoading` | Optional | `boolean` | `false` | Shows loading overlay on the chart area | | `title` | Optional | `string` | `"Operations vs Energy Cost"` | Chart title | | `unit` | Optional | `string` | `$/MWh` | Subtitle and tooltip unit label | ### `PowerModeTimelineChart` Timeline chart for power-mode state changes over time. Wraps [`TimelineChart`](/reference/ui/components/charts/#timelinechart) with mining-specific data shaping `agent-ready`
Source [src/domain/components/dashboard/power-mode-timeline-chart/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/dashboard/power-mode-timeline-chart/USAGE.md) [src/domain/components/dashboard/power-mode-timeline-chart/power-mode-timeline-chart.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/dashboard/power-mode-timeline-chart/power-mode-timeline-chart.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `data` | Optional | `PowerModeTimelineEntry[]` | `[]` | Initial power-mode entries (each with start/end ts + mode) | | `dataUpdates` | Optional | `PowerModeTimelineEntry[]` | `[]` | Streaming updates appended to the initial data | | `isLoading` | Optional | `boolean` | `false` | Show a loading skeleton instead of the chart | | `timezone` | Optional | `string` | `"UTC"` | IANA timezone string for x-axis tick formatting | | `title` | Optional | `string` | `CHART_TITLES.POWER_MODE_TIMELINE` | Chart title | ##### `PowerModeTimelineEntry` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `power_mode_group_aggr` | Optional | `Record` | - | Power mode keyed by miner; takes precedence over the miner's status for the bar's mode | | `status_group_aggr` | Optional | `Record` | - | Status keyed by miner; its keys decide which miners get a row, and an entry without it adds nothing | | `ts` | Optional | `number` | - | Sample time (epoch ms); consecutive samples with the same mode merge into one bar | ### `ThresholdLineChart` Line chart with optional horizontal threshold lines. Wraps [`ChartContainer`](/reference/ui/components/charts/#chartcontainer) and [`LineChart`](/reference/ui/components/charts/#linechart); pass `series` and optional `thresholds` via `data` `agent-ready`
Source [src/primitives/components/threshold-line-chart/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/threshold-line-chart/USAGE.md) [src/primitives/components/threshold-line-chart/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/threshold-line-chart/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `className` | Optional | `string` | - | Extra class on the container | | `data` | Optional | `ThresholdLineChartData` | - | `series` points plus optional `thresholds` lines | | `emptyMessage` | Optional | `string` | - | Message when data is missing or all zero | | `height` | Optional | `number` | `280` | Chart height in pixels (`360` when `isTall`) | | `isLegendVisible` | Optional | `boolean` | `true` | Legend with click-to-hide series | | `isTall` | Optional | `boolean` | `false` | When true, uses a taller default height (360px) | | `title` | Optional | `string` | - | Chart title (unit appended when `unit` is set) | | `unit` | Optional | `string` | - | Shown in title and axis/tooltip formatting | | `yTicksFormatter` | Optional | `(value: number) => string` | - | Custom Y-axis tick labels | ##### `ThresholdLineChartData` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `series` | Required | `ThresholdLineChartSeries[]` | - | Data lines; points with a non-finite timestamp are dropped. All-zero values render the empty state | | `thresholds` | Optional | `ThresholdLineChartThreshold[]` | - | Horizontal reference lines at a fixed `value`, spanning every timestamp in `series` | ### `TimelineChart` Discrete-event timeline chart (e.g. miner state over time) with a category legend. Supports streaming updates via `newData` `agent-ready`
Source [src/domain/components/timeline-chart/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/timeline-chart/USAGE.md) [src/domain/components/timeline-chart/timeline-chart.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/timeline-chart/timeline-chart.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `initialData` | Required | `TimelineChartData` | - | Initial timeline data | | `axisTitleText` | Optional | `AxisTitleText` | `{ x: "Time", y: "" }` | Axis title strings | | `height` | Optional | `number` | - | Chart pixel height | | `isLoading` | Optional | `boolean` | `false` | Show loader | | `newData` | Optional | `TimelineChartData` | - | Streaming updates appended to the initial data | | `range` | Optional | `ChartRange` | - | Visible time window | | `skipUpdates` | Optional | `boolean` | `false` | Ignore `newData` | | `title` | Optional | `string` | - | Chart title | ##### `TimelineChartData` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `datasets` | Required | `TimelineChartDataset[]` | - | Bar series; each dataset's `label` is its tooltip title and legend entry | | `labels` | Required | `string[]` | - | Row labels, one row each; a data point lands in the row whose label equals its `y`. Nothing renders when empty | ##### `ChartRange` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `max` | Required | `number \| Date` | - | Right edge of the time axis (`Date` or epoch ms) | | `min` | Required | `number \| Date` | - | Left edge of the time axis (`Date` or epoch ms); without a range the axis spans the data | ##### `AxisTitleText` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `x` | Required | `string` | - | Title under the time axis; hidden when empty | | `y` | Required | `string` | - | Title beside the row labels; hidden when empty | # Dashboard (/reference/ui/components/dashboard) Components for building dashboard layouts and containers. ## Prerequisites - Install the packages ```bash npm install \ @tetherto/mdk-react-devkit \ @tetherto/mdk-react-adapter \ @tetherto/mdk-ui-foundation ``` ```bash # Clone the MDK UI monorepo (adjust the URL to your fork if needed) git clone https://github.com/tetherto/mdk.git cd mdk/ui # Install dependencies and build packages (npm workspaces) npm install npm run build ``` Then add the dependency to your app's `package.json`: ```json { "dependencies": { "@tetherto/mdk-react-devkit": "*", "@tetherto/mdk-react-adapter": "*", "@tetherto/mdk-ui-foundation": "*" } } ``` Run `npm install` from the `mdk/ui` workspace root after your app is under `apps/` so npm links workspace packages. - Import styles: `import '@tetherto/mdk-react-devkit/styles.css'` ## Components @tetherto/mdk-react-devkit Import the public APIs on this page from [`@tetherto/mdk-react-devkit`](/reference/ui/#tethertomdk-react-devkit). ### `AlarmsBellButton` Top-bar bell button with a three-line severity badge (critical / high / medium). Counts are caller-provided so the button stays domain-agnostic; derive them from whatever alerts route your app serves. `onClick` fires for the bell itself; pass `onSeverityClick` to make each count its own button (severity-filtered deep-link). The two are independent — clicking a severity does not also fire `onClick` `agent-ready`
Source [src/domain/components/header-actions/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-actions/USAGE.md) [src/domain/components/header-actions/alarms-bell-button.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-actions/alarms-bell-button.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `className` | Optional | `string` | - | Additional class names | | `counts` | Optional | `AlarmsBellButtonCounts` | `{}` | Severity-bucketed alarm counts rendered in the stacked badge | | `label` | Optional | `string` | `"Active alarms"` | Accessible label. Defaults to "Active alarms" | | `onClick` | Optional | `((event: React.MouseEvent) => void)` | - | Click handler — typically opens an alerts panel or routes to /alerts | | `onSeverityClick` | Optional | `((severity: AlarmSeverity, event: React.MouseEvent) => void)` | - | Click handler for an individual severity count. When provided, each badge row becomes its own button so an operator can jump straight to the alerts page filtered by that severity (e.g. `/alerts?severity=critical`). When omitted, the counts render as plain (non-interactive) text | ##### `AlarmsBellButtonCounts` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `critical` | Optional | `number` | - | Critical alarm count, shown as the top badge row; omit to hide the row | | `high` | Optional | `number` | - | High alarm count, shown as the middle badge row; omit to hide the row | | `medium` | Optional | `number` | - | Medium alarm count, shown as the bottom badge row; omit to hide the row | ### `DashboardDateRangePicker` Dashboard-friendly wrapper around the core [`DateRangePicker`](/reference/ui/components/forms/#daterangepicker) that speaks `{ start, end }` epoch-millisecond timestamps instead of `Date` objects, the shape a history route usually takes as query params `agent-ready`
Source [src/domain/components/dashboard/dashboard-date-range-picker/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/dashboard/dashboard-date-range-picker/USAGE.md) [src/domain/components/dashboard/dashboard-date-range-picker/dashboard-date-range-picker.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/dashboard/dashboard-date-range-picker/dashboard-date-range-picker.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `onChange` | Required | `(next: DashboardDateRange) => void` | - | Fires with the next `{ start, end }` window when the user applies a range | | `value` | Required | `DashboardDateRange` | - | Current range as `{ start, end }` epoch-millisecond timestamps | | `className` | Optional | `string` | - | Optional class hook | | `dateFormat` | Optional | `string` | `"dd/MM/yyyy"` | Display format. Defaults to `dd/MM/yyyy` | | `disabled` | Optional | `boolean` | `false` | Disable the trigger | ##### `DashboardDateRange` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `end` | Required | `number` | - | Window end, milliseconds since epoch | | `start` | Required | `number` | - | Window start, milliseconds since epoch | ### `DEFAULT_TIMELINE_OPTIONS` Canonical short-form intervals for the dashboard timeline selector `advanced`
Source [src/domain/components/dashboard/timeline-selector/timeline-options.ts](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/dashboard/timeline-selector/timeline-options.ts)
### `ExportButton` Split-button trigger for downloading the current dashboard state. The left half labels the action (`↓ Export`); the right half opens a dropdown with the available formats and invokes `onExport(format)` on selection. When the handler returns a promise, the button shows a spinner and stays disabled until it settles `agent-ready`
Source [src/domain/components/dashboard/export-button/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/dashboard/export-button/USAGE.md) [src/domain/components/dashboard/export-button/export-button.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/dashboard/export-button/export-button.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `onExport` | Required | `(format: ExportFormat) => void \| Promise` | - | Fires with the chosen format when the user picks an item. Return a promise to show a loading state until it settles; a rejection clears it and is not rethrown, so report failures inside the handler | | `className` | Optional | `string` | - | Optional class hook on the wrapper | | `disabled` | Optional | `boolean` | `false` | Disable the button | | `formats` | Optional | `readonly ExportFormat[]` | `['csv', 'json']` | Formats to offer in the dropdown — defaults to `['csv', 'json']` | | `label` | Optional | `string` | `"Export"` | [**`Button`**](/reference/ui/components/actions/#button) label — defaults to `'Export'` | ### `HeaderConsumptionBox` Single-row consumption cell for the dashboard's header strip. The `1.663` style numeric is rendered in orange (the warning token) `agent-ready`
Source [src/domain/components/header-stats/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-stats/USAGE.md) [src/domain/components/header-stats/header-consumption-box.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-stats/header-consumption-box.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `className` | Optional | `string` | - | Additional class names | | `icon` | Optional | `React.ReactNode` | - | Icon shown next to the stat | | `unit` | Optional | `string` | `MW` | Unit label — defaults to `MW` | | `valueMw` | Optional | `number` | - | Current site-level power consumption, in megawatts | ### `HeaderEfficiencyBox` Single-row efficiency cell for the dashboard's header strip. Displays the W/TH/s metric derived from `power_w / hashrate_th` `agent-ready`
Source [src/domain/components/header-stats/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-stats/USAGE.md) [src/domain/components/header-stats/header-efficiency-box.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-stats/header-efficiency-box.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `className` | Optional | `string` | - | Additional class names | | `icon` | Optional | `React.ReactNode` | - | Icon shown next to the stat | | `unit` | Optional | `string` | `W/TH/S` | Unit label — defaults to `W/TH/S` | | `valueWthS` | Optional | `number` | - | Efficiency in watts per TH/s | ### `HeaderHashrateBox` Two-row hashrate cell for the dashboard's header strip. Shows the app-side and pool-side aggregate hashrate side by side. Values fall back to `—` when undefined `agent-ready`
Source [src/domain/components/header-stats/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-stats/USAGE.md) [src/domain/components/header-stats/header-hashrate-box.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-stats/header-hashrate-box.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `appLabel` | Optional | `string` | `WEBAPP_SHORT_NAME ("APP")` | [**`Label`**](/reference/ui/components/forms/#label) for the app-side row | | `appPhs` | Optional | `number` | - | App-side aggregate hashrate in PH/s | | `className` | Optional | `string` | - | Additional class names | | `fractionDigits` | Optional | `number` | `3` | Decimal places shown for both values — defaults to `3` | | `icon` | Optional | `React.ReactNode` | - | Icon shown next to the stat | | `poolPhs` | Optional | `number` | - | Pool-side aggregate hashrate in PH/s | | `unit` | Optional | `string` | `PH/s` | Hashrate unit label — defaults to `PH/s` | ### `HeaderMinersBox` Two-row miner-count cell for the dashboard's header strip. Top row carries the app-side `online / error / offline` breakdown; the bottom row shows the pool-side equivalent. Numbers fall back to `—` when undefined `agent-ready`
Source [src/domain/components/header-stats/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-stats/USAGE.md) [src/domain/components/header-stats/header-miners-box.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-stats/header-miners-box.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `appLabel` | Optional | `string` | `WEBAPP_SHORT_NAME ("APP")` | [**`Label`**](/reference/ui/components/forms/#label) for the app-side row | | `appTotal` | Optional | `number` | - | Optional app-side meta line — total miners reporting to the app | | `className` | Optional | `string` | - | Additional class names | | `error` | Optional | `number` | - | Miners flagged in warning (the small amber count) | | `icon` | Optional | `React.ReactNode` | - | Icon shown next to the "Miners" label. Caller-provided so the package stays icon-agnostic | | `offline` | Optional | `number` | - | Miners offline (the small red count) | | `online` | Optional | `number` | - | Online miners (the `158` numerator) | | `poolMismatch` | Optional | `number` | - | Optional pool-side mismatch count (red) | | `poolOnline` | Optional | `number` | - | Optional pool-side online count (green) | | `poolTotal` | Optional | `number` | - | Optional pool-side meta — total miners as reported by upstream pools | | `total` | Optional | `number` | - | Total miners across the site (denominator of the `158 / 2,188` ratio) | ### `HeaderStatsBar` Horizontal flex strip that hosts the dashboard's stat boxes ([**`HeaderMinersBox`**](/reference/ui/components/dashboard/#headerminersbox), [**`HeaderHashrateBox`**](/reference/ui/components/dashboard/#headerhashratebox), [**`HeaderConsumptionBox`**](/reference/ui/components/dashboard/#headerconsumptionbox), [**`HeaderEfficiencyBox`**](/reference/ui/components/dashboard/#headerefficiencybox)). Lives inside [``](/reference/ui/components/navigation/#appheader) as the middle slot. Slot-based: the caller decides which boxes to render and in which order. The bar interleaves an angled chevron divider between adjacent children `agent-ready`
Source [src/domain/components/header-stats/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-stats/USAGE.md) [src/domain/components/header-stats/header-stats-bar.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-stats/header-stats-bar.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `children` | Required | `React.ReactNode` | - | Stat boxes to render in order, left-to-right | | `className` | Optional | `string` | - | Optional class hook | ### `ProfileMenu` Top-bar profile dropdown. Wraps the core DropdownMenu primitive with the user-avatar icon as the trigger. Items are caller-provided so the menu surface stays application-driven `agent-ready`
Source [src/domain/components/header-actions/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-actions/USAGE.md) [src/domain/components/header-actions/profile-menu.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/header-actions/profile-menu.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `items` | Required | `ProfileMenuItem[]` | - | Items rendered in the dropdown, top-to-bottom | | `className` | Optional | `string` | - | Additional class names | | `icon` | Optional | `React.ReactNode` | `` | Override the trigger icon — defaults to the user-avatar icon | | `label` | Optional | `string` | `"Profile menu"` | Accessible label for the trigger button | | `user` | Optional | `React.ReactNode` | - | Optional user label rendered at the top of the dropdown (e.g. an email) | ##### `ProfileMenuItem` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `label` | Required | `string` | - | Item text; also used as the React key, so keep it unique within `items` | | `onSelect` | Required | `() => void` | - | Called when the item is chosen by click or keyboard | | `danger` | Optional | `boolean` | - | Render as a destructive action (red) | | `description` | Optional | `React.ReactNode` | - | Optional secondary line under the label (e.g. "Current: Europe/Podgorica") | | `disabled` | Optional | `boolean` | - | Disable the menu item | | `icon` | Optional | `React.ReactNode` | - | Optional leading icon | ### `SiteStatsBar` Site-level summary strip composed from [`WidgetTopRow`](/reference/ui/components/widgets/#widgettoprow) (title + power) and [`GenericDataBox`](/reference/ui/components/widgets/#genericdatabox) (hashrate / miner-count / container-count). Designed to sit at the top of a dashboard page above the chart cards `agent-ready`
Source [src/domain/components/dashboard/site-stats-bar/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/dashboard/site-stats-bar/USAGE.md) [src/domain/components/dashboard/site-stats-bar/site-stats-bar.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/dashboard/site-stats-bar/site-stats-bar.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `title` | Required | `string` | - | Site label rendered in the header row | | `className` | Optional | `string` | - | Optional class hook | | `containerCount` | Optional | `number` | - | Total container count across the site | | `hashrateUnit` | Optional | `string` | `"TH/s"` | Hashrate display unit — defaults to `TH/s` | | `isLoading` | Optional | `boolean` | `false` | Render a skeleton bar while data is loading | | `minerCount` | Optional | `number` | - | Total miner count across the site | | `power` | Optional | `number` | - | Current site-level power consumption, in watts. It is displayed divided by 1000, so `powerUnit` names the kilo unit (`kW` by default) | | `powerUnit` | Optional | `string` | `"kW"` | Display unit for `power` — defaults to `kW` | | `totalHashrate` | Optional | `number` | - | Aggregate hashrate, in TH/s | ### `TimelineSelector` Dropdown for picking the dashboard time range. Wraps `core/Select` and the canonical option list from `getTimelineOptions`. Controlled: the page owns the selected timeline and passes it to its queries `agent-ready`
Source [src/domain/components/dashboard/timeline-selector/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/dashboard/timeline-selector/USAGE.md) [src/domain/components/dashboard/timeline-selector/timeline-selector.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/dashboard/timeline-selector/timeline-selector.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `onChange` | Required | `(next: string) => void` | - | Called whenever the user picks a new option | | `value` | Required | `string` | - | Currently selected timeline value (e.g. `'1m'`, `'5m'`) | | `className` | Optional | `string` | - | Tailwind/BEM class hook on the trigger | | `label` | Optional | `string` | `"Time range"` | ARIA label / placeholder for the trigger | | `options` | Optional | `TimelineOption[]` | `getTimelineOptions()` | Available options — defaults to `getTimelineOptions`. Pass a custom list to localise labels or restrict the range | # Dialog (/reference/ui/components/dialogs) Modal and dialog components for user interactions. ## Prerequisites - Install the packages ```bash npm install \ @tetherto/mdk-react-devkit \ @tetherto/mdk-react-adapter \ @tetherto/mdk-ui-foundation ``` ```bash # Clone the MDK UI monorepo (adjust the URL to your fork if needed) git clone https://github.com/tetherto/mdk.git cd mdk/ui # Install dependencies and build packages (npm workspaces) npm install npm run build ``` Then add the dependency to your app's `package.json`: ```json { "dependencies": { "@tetherto/mdk-react-devkit": "*", "@tetherto/mdk-react-adapter": "*", "@tetherto/mdk-ui-foundation": "*" } } ``` Run `npm install` from the `mdk/ui` workspace root after your app is under `apps/` so npm links workspace packages. - Import styles: `import '@tetherto/mdk-react-devkit/styles.css'` ## Components @tetherto/mdk-react-devkit Import the public APIs on this page from [`@tetherto/mdk-react-devkit`](/reference/ui/#tethertomdk-react-devkit). ### `AlertDialogAction` Primary confirmation button inside an ``; clicking dismisses the dialog and runs the action handler `agent-ready`
Source [src/primitives/components/alert-dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/USAGE.md) [src/primitives/components/alert-dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/index.tsx)
### `AlertDialogCancel` Secondary dismiss button inside an ``; closes the dialog without invoking the destructive action `agent-ready`
Source [src/primitives/components/alert-dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/USAGE.md) [src/primitives/components/alert-dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/index.tsx)
### `AlertDialogContent` Modal content surface for an `` — renders the centered panel above the overlay with focus trap `agent-ready`
Source [src/primitives/components/alert-dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/USAGE.md) [src/primitives/components/alert-dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/index.tsx)
### `AlertDialogDescription` Supporting body text inside an ``; conveys the consequences of the action being confirmed `agent-ready`
Source [src/primitives/components/alert-dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/USAGE.md) [src/primitives/components/alert-dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/index.tsx)
### `AlertDialogFooter` Right-aligned action row inside an `` — hosts the cancel and confirm buttons `agent-ready`
Source [src/primitives/components/alert-dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/USAGE.md) [src/primitives/components/alert-dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/index.tsx)
### `AlertDialogHeader` Top section of an `` that groups the title and description above the action row `agent-ready`
Source [src/primitives/components/alert-dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/USAGE.md) [src/primitives/components/alert-dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/index.tsx)
### `AlertDialogOverlay` Full-viewport scrim rendered behind an `` to block interaction with the page `agent-ready`
Source [src/primitives/components/alert-dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/USAGE.md) [src/primitives/components/alert-dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/index.tsx)
### `AlertDialogTitle` Prominent title text inside an `` summarising the action that requires confirmation `agent-ready`
Source [src/primitives/components/alert-dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/USAGE.md) [src/primitives/components/alert-dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert-dialog/index.tsx)
### `DialogContent` Centered modal surface for a `` — renders above the overlay with focus trap and Escape-to-close `agent-ready`
Source [src/primitives/components/dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/dialog/USAGE.md) [src/primitives/components/dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/dialog/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `bare` | Optional | `boolean` | `false` | Applies `mdk-dialog__header--bare` to the header | | `closable` | Optional | `boolean` | - | Shows an ✕ close button in the header | | `closeOnClickOutside` | Optional | `boolean` | `true` | Whether clicking the overlay closes the dialog | | `closeOnEscape` | Optional | `boolean` | `true` | Whether pressing Escape closes the dialog | | `description` | Optional | `string` | - | Renders [`DialogDescription`](/reference/ui/components/dialogs/#dialogdescription) below the title | | `onClose` | Optional | `VoidFunction` | - | Fired when the ✕ button is clicked | | `title` | Optional | `string` | - | Renders [`DialogTitle`](/reference/ui/components/dialogs/#dialogtitle) (and optional [`DialogDescription`](/reference/ui/components/dialogs/#dialogdescription)) inside the header | ### `DialogDescription` Supporting body copy inside a `` rendered below the title `agent-ready`
Source [src/primitives/components/dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/dialog/USAGE.md) [src/primitives/components/dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/dialog/index.tsx)
### `DialogFooter` Action row at the bottom of a `` — typically primary/secondary buttons `agent-ready`
Source [src/primitives/components/dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/dialog/USAGE.md) [src/primitives/components/dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/dialog/index.tsx)
### `DialogHeader` Top region of a `` that groups the title and description `agent-ready`
Source [src/primitives/components/dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/dialog/USAGE.md) [src/primitives/components/dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/dialog/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `bare` | Optional | `boolean` | `false` | Applies `mdk-dialog__header--bare` to the header | | `closable` | Optional | `boolean` | - | Shows an ✕ close button in the header | | `onClose` | Optional | `VoidFunction` | - | Fired when the ✕ button is clicked | ### `DialogOverlay` Full-viewport scrim rendered behind an open `` to block background interaction `agent-ready`
Source [src/primitives/components/dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/dialog/USAGE.md) [src/primitives/components/dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/dialog/index.tsx)
### `DialogTitle` Prominent title text at the top of a `` summarising the modal's purpose `agent-ready`
Source [src/primitives/components/dialog/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/dialog/USAGE.md) [src/primitives/components/dialog/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/dialog/index.tsx)
# Display (/reference/ui/components/display) Components for displaying and formatting data. ## Prerequisites - Install the packages ```bash npm install \ @tetherto/mdk-react-devkit \ @tetherto/mdk-react-adapter \ @tetherto/mdk-ui-foundation ``` ```bash # Clone the MDK UI monorepo (adjust the URL to your fork if needed) git clone https://github.com/tetherto/mdk.git cd mdk/ui # Install dependencies and build packages (npm workspaces) npm install npm run build ``` Then add the dependency to your app's `package.json`: ```json { "dependencies": { "@tetherto/mdk-react-devkit": "*", "@tetherto/mdk-react-adapter": "*", "@tetherto/mdk-ui-foundation": "*" } } ``` Run `npm install` from the `mdk/ui` workspace root after your app is under `apps/` so npm links workspace packages. - Import styles: `import '@tetherto/mdk-react-devkit/styles.css'` ## Components @tetherto/mdk-react-devkit Import the public APIs on this page from [`@tetherto/mdk-react-devkit`](/reference/ui/#tethertomdk-react-devkit). ### `Avatar` Circular avatar surface that shows a profile image with a graceful text fallback when the image fails to load `agent-ready`
Source [src/primitives/components/avatar/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/avatar/USAGE.md) [src/primitives/components/avatar/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/avatar/index.tsx)
### `AvatarFallback` Initials or icon placeholder shown inside [``](/reference/ui/components/display/#avatar) while the image loads or when no image is available `agent-ready`
Source [src/primitives/components/avatar/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/avatar/USAGE.md) [src/primitives/components/avatar/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/avatar/index.tsx)
### `AvatarImage` Profile image slot inside [``](/reference/ui/components/display/#avatar) — renders `src` and triggers the fallback on load failure `agent-ready`
Source [src/primitives/components/avatar/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/avatar/USAGE.md) [src/primitives/components/avatar/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/avatar/index.tsx)
### `Badge` [**`Badge`**](/reference/ui/components/display/#badge) component - Display badge with number or dot `agent-ready`
Source [src/primitives/components/badge/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/badge/USAGE.md) [src/primitives/components/badge/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/badge/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `children` | Optional | `React.ReactNode` | - | [**`Badge`**](/reference/ui/components/display/#badge) content (wraps children with badge) | | `className` | Optional | `string` | - | Custom className for badge | | `color` | Optional | `"success" \| "info" \| "warning" \| "error" \| "primary" \| "secondary" \| "default"` | `"primary"` | Color variant | | `count` | Optional | `number` | `0` | Number to display in badge If > overflowCount, will show "overflowCount+" | | `dot` | Optional | `boolean` | `false` | Show badge as a dot | | `offset` | Optional | `[number, number]` | `[0, 0]` | Offset position [x, y] in pixels | | `overflowCount` | Optional | `number` | `99` | Maximum count to display | | `showZero` | Optional | `boolean` | `false` | Whether to show badge when count is 0 | | `size` | Optional | `"sm" \| "md" \| "lg"` | `"md"` | [**`Badge`**](/reference/ui/components/display/#badge) size | | `square` | Optional | `boolean` | `false` | Square badge (no border-radius) | | `status` | Optional | `"success" \| "warning" \| "error" \| "default" \| "processing"` | - | Status badge (small dot badge with text) | | `text` | Optional | `string` | - | Custom badge content (overrides count) | | `title` | Optional | `string` | - | [**`Badge`**](/reference/ui/components/display/#badge) title for accessibility | | `wrapperClassName` | Optional | `string` | - | Custom className for wrapper | ### `BtcAveragePrice` Read-only BTC average price label for reporting toolbars `agent-ready`
Source [src/primitives/components/btc-average-price/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/btc-average-price/USAGE.md) [src/primitives/components/btc-average-price/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/btc-average-price/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `label` | Optional | `string` | `"BTC Average Price"` | [**`Label`**](/reference/ui/components/forms/#label) for the BTC average price | | `price` | Optional | `number \| null` | - | BTC price in USD; formatted with grouping and no decimal places. When `null`, `undefined`, non-finite, or negative, the value shows `-` (`FALLBACK` from format utils) | ### `DataLabel` Read-only period label (`PERIOD: start - end`) with timezone-aware dates. Pure: the timezone comes in as a prop `agent-ready`
Source [src/primitives/components/data-label/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/data-label/USAGE.md) [src/primitives/components/data-label/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/data-label/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `endDate` | Optional | `Date \| null` | - | Range end; formatted in the active timezone (`dd/MM/yy`) | | `label` | Optional | `string` | `"PERIOD"` | [**`Label`**](/reference/ui/components/forms/#label) text; defaults to `PERIOD` | | `startDate` | Optional | `Date \| null` | - | Range start; formatted in the active timezone (`dd/MM/yy`) | | `timezone` | Optional | `string` | - | IANA timezone the dates are shown in (e.g. `'UTC'`). Defaults to the browser's timezone; pass `useTimezone().timezone` to follow the user's selected one | ### `Indicator` [**`Indicator`**](/reference/ui/components/display/#indicator) component - display status with colored background and label `agent-ready`
Source [src/primitives/components/indicator/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/indicator/USAGE.md) [src/primitives/components/indicator/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/indicator/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `children` | Optional | `React.ReactNode` | - | Children content (can include text, icons, multiple elements) | | `className` | Optional | `string` | - | Custom className for the root element | | `color` | Optional | `"red" \| "gray" \| "blue" \| "yellow" \| "green" \| "purple" \| "amber" \| "slate"` | `"gray"` | Color variant of the indicator | | `onClick` | Optional | `(VoidFunction & React.MouseEventHandler)` | - | Click handler | | `size` | Optional | `"sm" \| "md" \| "lg"` | `"md"` | Size variant of the indicator | | `vertical` | Optional | `boolean` | `false` | When true, adds extra spacing between child elements and stacks them vertically. Useful for displaying multiple pieces of information (e.g. status + count) in a clear way | ### `Tag` [**`Tag`**](/reference/ui/components/display/#tag) component - display labels, categories, or status `agent-ready`
Source [src/primitives/components/tag/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/tag/USAGE.md) [src/primitives/components/tag/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/tag/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `children` | Optional | `React.ReactNode` | - | Children content | | `className` | Optional | `string` | - | Custom className for the root element | | `color` | Optional | `"red" \| "blue" \| "green" \| "amber" \| "dark"` | `"dark"` | Color variant of the tag | ### `Typography` [**`Typography`**](/reference/ui/components/display/#typography) component for consistent text styling `agent-ready`
Source [src/primitives/components/typography/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/typography/USAGE.md#props-detail) [src/primitives/components/typography/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/typography/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `align` | Optional | `"center" \| "left" \| "right" \| "justify"` | - | Text alignment | | `className` | Optional | `string` | - | Custom className | | `color` | Optional | `"success" \| "warning" \| "error" \| "primary" \| "default" \| "muted"` | `"default"` | Token-based text color | | `size` | Optional | `"sm" \| "md" \| "lg" \| "xs" \| "xl" \| "2xl" \| "3xl" \| "4xl"` | - | Text size; defaults to the variant's size when unset | | `truncate` | Optional | `boolean` | `false` | Truncate text with ellipsis | | `variant` | Optional | `"body" \| "caption" \| "secondary" \| "heading1" \| "heading2" \| "heading3"` | `"body"` | Determines the rendered HTML element and base style | | `weight` | Optional | `"medium" \| "normal" \| "light" \| "semibold" \| "bold"` | - | Font weight; defaults to the variant's weight when unset | # Feedback (/reference/ui/components/feedback) Components for providing feedback and notifications to users. ## Prerequisites - Install the packages ```bash npm install \ @tetherto/mdk-react-devkit \ @tetherto/mdk-react-adapter \ @tetherto/mdk-ui-foundation ``` ```bash # Clone the MDK UI monorepo (adjust the URL to your fork if needed) git clone https://github.com/tetherto/mdk.git cd mdk/ui # Install dependencies and build packages (npm workspaces) npm install npm run build ``` Then add the dependency to your app's `package.json`: ```json { "dependencies": { "@tetherto/mdk-react-devkit": "*", "@tetherto/mdk-react-adapter": "*", "@tetherto/mdk-ui-foundation": "*" } } ``` Run `npm install` from the `mdk/ui` workspace root after your app is under `apps/` so npm links workspace packages. - Import styles: `import '@tetherto/mdk-react-devkit/styles.css'` ## Components @tetherto/mdk-react-devkit ### Core alert ```tsx ``` Contextual feedback banner for success, info, warning, and error messages. Supports icons, close button, an action slot, and an optional full-width banner mode. #### Notes - Once closed via the ✕ button, the component returns `null` and cannot be reopened without unmounting/remounting - Setting `description` automatically adds the `mdk-alert--with-description` modifier for extra spacing #### Example [#core-alert-example] ```tsx /** * Runnable example for Alert. */
) ``` @tetherto/mdk-react-devkit Import the public APIs on this page from [`@tetherto/mdk-react-devkit`](/reference/ui/#tethertomdk-react-devkit). ### `AlarmContents` Body region of an alarm card listing the alert details and recommended next actions `agent-ready`
Source [src/domain/components/alarm/alarm-contents/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/alarm/alarm-contents/USAGE.md) [src/domain/components/alarm/alarm-contents/alarm-contents.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/alarm/alarm-contents/alarm-contents.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `alarmsData` | Required | `unknown` | - | Alert entries to render. Falls back to [`EmptyState`](/reference/ui/components/feedback/#emptystate) when empty or falsy | | `onNavigate` | Required | `(path: string) => void` | - | Navigation callback forwarded to each [`AlarmRow`](/reference/ui/components/feedback/#alarmrow) for click-through routing | ### `AlarmRow` Single alarm-feed row with severity dot, timestamp, source device, and the alert message `agent-ready`
Source [src/domain/components/alarm/alarm-row/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/alarm/alarm-row/USAGE.md) [src/domain/components/alarm/alarm-row/alarm-row.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/alarm/alarm-row/alarm-row.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `data` | Required | `TimelineItemData` | - | The alarm entry to render | | `onNavigate` | Required | `(path: string) => void` | - | Called when the row is clicked; receives the alarm `uuid` as the path segment | ##### `TimelineItemData` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `children` | Required | `React.ReactNode` | - | Prebuilt [`LogItem`](/reference/ui/components/monitoring/#logitem) node, built by the caller; not rendered by [`AlarmRow`](/reference/ui/components/feedback/#alarmrow) | | `dot` | Required | `React.ReactNode` | - | Timeline marker (e.g. [`LogDot`](/reference/ui/components/monitoring/#logdot)), built by the caller; not rendered by [`AlarmRow`](/reference/ui/components/feedback/#alarmrow) | | `item` | Required | `AlarmItemData` | - | The alarm rendered by the row: `title`, `subtitle`, `body` (split on `\|` into lines), `status` (`Critical`, `High` or `Medium` sets the color and icon) and `uuid` (click target `/alerts/`) | ### `CoreAlert` Inline alert banner with type-based icon, optional description, dismissible close button, and trailing action slot `agent-ready`
Source [src/primitives/components/alert/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert/USAGE.md) [src/primitives/components/alert/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/alert/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `action` | Optional | `React.ReactNode` | - | Action element rendered to the right | | `banner` | Optional | `boolean` | `false` | Display as full-width banner (no border radius, no margin) | | `className` | Optional | `string` | - | Custom className | | `closable` | Optional | `boolean` | `false` | Shows an ✕ button; clicking it hides the alert | | `description` | Optional | `React.ReactNode` | - | Secondary/detail content shown below the title | | `icon` | Optional | `React.ReactNode` | - | Custom icon (used when showIcon is true) | | `onClose` | Optional | `React.MouseEventHandler` | - | Called when close button is clicked | | `showIcon` | Optional | `boolean` | `false` | Renders the type icon (or the `icon` override) before the content | | `style` | Optional | `React.CSSProperties` | - | Custom styles | | `title` | Optional | `React.ReactNode` | - | Main message | | `type` | Optional | `"success" \| "info" \| "warning" \| "error"` | `"info"` | Controls the color scheme and default icon | ### `EmptyState` Empty state component for displaying placeholder content when no data is available `agent-ready`
Source [src/primitives/components/empty-state/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/empty-state/USAGE.md) [src/primitives/components/empty-state/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/empty-state/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `description` | Required | `React.ReactNode` | - | Description text or ReactNode displayed below the image | | `className` | Optional | `string` | - | Additional CSS class name | | `image` | Optional | `EmptyStateImage` | `"default"` | Image to display. Use "default" for the standard illustration, "simple" for a minimal icon, or pass a custom ReactNode | | `size` | Optional | `"sm" \| "md" \| "lg"` | `"md"` | Size variant controlling spacing and icon dimensions | ### `ErrorCard` Error card component for displaying error messages `agent-ready`
Source [src/primitives/components/error-card/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/error-card/USAGE.md) [src/primitives/components/error-card/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/error-card/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `error` | Required | `string` | - | Error message string. Supports `\\n` for line breaks | | `className` | Optional | `string` | - | Additional CSS class name | | `title` | Optional | `string` | `"Errors"` | Title displayed above the error message | | `variant` | Optional | `"inline" \| "card"` | `"card"` | Display variant. "card" shows a bordered container, "inline" shows flat text | ### `Loader` [**`Loader`**](/reference/ui/components/feedback/#loader) component - display pulsing dots animation `agent-ready`
Source [src/primitives/components/loader/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/loader/USAGE.md) [src/primitives/components/loader/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/loader/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `className` | Optional | `string` | - | Custom className for the root element | | `color` | Optional | `"red" \| "gray" \| "blue" \| "amber" \| "orange"` | `"orange"` | Color variant of the loader | | `count` | Optional | `3 \| 5 \| 7` | `5` | Number of dots to display | | `inline` | Optional | `boolean` | `false` | Render as an inline activity indicator instead of a block loading state. The default reserves a fixed 200px so a panel standing in for absent content does not collapse and then jump. Inline, that height is wrong: it strands the dots ~100px below the thing they belong to and centers them against surrounding text, which reads as frozen | | `size` | Optional | `number` | `10` | Size of each dot in pixels | ### `SkeletonBlock` Rectangular shimmer placeholder used to hint at content shape while data is loading `agent-ready`
Source [src/primitives/components/skeleton/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/skeleton/USAGE.md) [src/primitives/components/skeleton/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/skeleton/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `borderRadius` | Optional | `string \| number` | - | Border radius; ignored when `circle` is true | | `circle` | Optional | `boolean` | `false` | Renders a perfect circle using `height` as the diameter | | `className` | Optional | `string` | - | Additional class for the element | | `height` | Optional | `string \| number` | - | Height in pixels (number) or any CSS value (string) | | `width` | Optional | `string \| number` | - | Width in pixels (number) or any CSS value (string) | ### `Spinner` [**`Spinner`**](/reference/ui/components/feedback/#spinner) component - display loading state with rotating squares `agent-ready`
Source [src/primitives/components/spinner/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/spinner/USAGE.md) [src/primitives/components/spinner/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/spinner/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `className` | Optional | `string` | - | Custom className for the root element | | `color` | Optional | `"primary" \| "secondary"` | `"primary"` | Color variant of the spinner | | `fullScreen` | Optional | `boolean` | `false` | Whether to display in fullscreen mode | | `label` | Optional | `string` | - | Optional label text to display below the spinner | | `size` | Optional | `"sm" \| "md" \| "lg"` | `"md"` | Size variant of the spinner | | `speed` | Optional | `"slow" \| "normal" \| "fast"` | `"normal"` | Speed of the animation | | `type` | Optional | `"circle" \| "square"` | `"square"` | Type of spinner animation | ### `Toast` Single transient notification rendered inside the [``](/reference/ui/components/feedback/#toaster) viewport. Use `variant` to convey intent (`success`, `info`, `warning`, `error`); pair with `` and `` for structured content, or wrap a `` for a single inline call-to-action. Most apps will trigger toasts imperatively via the `useToast` hook rather than mounting [``](/reference/ui/components/feedback/#toast) directly `agent-ready`
Source [src/primitives/components/toast/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/toast/USAGE.md) [src/primitives/components/toast/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/toast/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `title` | Required | `string` | - | Title shown at the top of the toast | | `description` | Optional | `string` | - | Body text | | `duration` | Optional | `number` | - | Auto-dismiss after N ms | | `icon` | Optional | `React.JSX.Element` | - | Override the default variant icon | | `onOpenChange` | Optional | `((open: boolean) => void)` | - | Open-state change handler | | `open` | Optional | `boolean` | - | Controlled open state | | `variant` | Optional | `"success" \| "info" \| "warning" \| "error"` | `"info"` | Determines the icon and accent | ### `Toaster` Top-level provider + viewport that hosts every toast triggered via `useToast`. Mount once near the root of your app (typically inside ``). Without a [``](/reference/ui/components/feedback/#toaster) in the tree, `useToast` calls are no-ops. Position and visual stacking are controlled by CSS tokens; no props are required for the default top-left placement `agent-ready`
Source [src/primitives/components/toast/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/toast/USAGE.md) [src/primitives/components/toast/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/toast/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `children` | Required | `React.ReactNode` | - | The [``](/reference/ui/components/feedback/#toast) elements to render | | `position` | Optional | `"top-left" \| "top-right" \| "bottom-left" \| "bottom-right" \| "top-center" \| "bottom-center"` | `"top-left"` | Where the viewport is anchored | # Filter (/reference/ui/components/filters) Components for filtering and searching data. ## Prerequisites - Install the packages ```bash npm install \ @tetherto/mdk-react-devkit \ @tetherto/mdk-react-adapter \ @tetherto/mdk-ui-foundation ``` ```bash # Clone the MDK UI monorepo (adjust the URL to your fork if needed) git clone https://github.com/tetherto/mdk.git cd mdk/ui # Install dependencies and build packages (npm workspaces) npm install npm run build ``` Then add the dependency to your app's `package.json`: ```json { "dependencies": { "@tetherto/mdk-react-devkit": "*", "@tetherto/mdk-react-adapter": "*", "@tetherto/mdk-ui-foundation": "*" } } ``` Run `npm install` from the `mdk/ui` workspace root after your app is under `apps/` so npm links workspace packages. - Import styles: `import '@tetherto/mdk-react-devkit/styles.css'` ## Components @tetherto/mdk-react-devkit Import the public APIs on this page from [`@tetherto/mdk-react-devkit`](/reference/ui/#tethertomdk-react-devkit). ### `ListViewFilter` Toolbar of dropdown/checkbox filters that drive a list view; emits the active filter set to the parent `agent-ready`
Source [src/primitives/components/list-view-filter/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/list-view-filter/USAGE.md) [src/primitives/components/list-view-filter/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/list-view-filter/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `onChange` | Required | `(selections: CascaderValue[]) => void` | - | Callback when filters change | | `options` | Required | `CascaderOption[]` | - | [**`Cascader`**](/reference/ui/components/forms/#cascader) options for filtering | | `className` | Optional | `string` | - | Custom className for the filter button | | `filterKey` | Optional | `string` | `"default"` | Optional key to force re-mounting the [**`Cascader`**](/reference/ui/components/forms/#cascader) when filters change Useful if you want to reset the internal state of the [**`Cascader`**](/reference/ui/components/forms/#cascader) when filters change | | `localFilters` | Optional | `LocalFilters` | - | Current filter values as key-value pairs Example: { type: 'Antminer S19XP H', status: ['active', 'pending'] } | ### `ReportTimeFrameSelector` Reporting-period selector with preset windows (1d / 7d / 30d / custom range) `agent-ready`
Source [src/domain/components/timeframe/report-time-frame-selector/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/timeframe/report-time-frame-selector/USAGE.md) [src/domain/components/timeframe/report-time-frame-selector/report-time-frame-selector.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/timeframe/report-time-frame-selector/report-time-frame-selector.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `dateRange` | Required | `[Date, Date]` | - | Custom date range as a `[start, end]` tuple | | `presetTimeFrame` | Required | `number \| null` | - | Selected preset in days (`1`, `7`, `30`); `null` means the custom range is active | | `setDateRange` | Required | `(value: [Date, Date]) => void` | - | Update the custom date range | | `setPresetTimeFrame` | Required | `(value: number \| null) => void` | - | Update the preset selection | ### `TimeframeControls` Date-range picker for financial reporting pages — supports year, month, and week granularity via connected selects `agent-ready`
Source [src/domain/components/timeframe/timeframe-controls/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/timeframe/timeframe-controls/USAGE.md) [src/domain/components/timeframe/timeframe-controls/timeframe-controls.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/timeframe/timeframe-controls/timeframe-controls.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `dateRange` | Optional | `TimeframeControlsDateRange` | - | Current date range | | `hint` | Optional | `string` | - | Helper text below the controls | | `isMonthSelectVisible` | Optional | `boolean` | `true` | Show month selector | | `isWeekSelectVisible` | Optional | `boolean` | `true` | Show week selector | | `layout` | Optional | `"horizontal" \| "stacked"` | `"horizontal"` | Layout direction | | `onRangeChange` | Optional | `TimeframeControlsOnRangeChange` | - | Called when the range changes | | `onReset` | Optional | `VoidFunction` | - | Called when the Reset button is clicked | | `onTimeframeTypeChange` | Optional | `(type: TimeframeTypeValue) => void` | - | Called when timeframe type changes | | `showResetButton` | Optional | `boolean` | `false` | Shows the Reset button | | `timeframeType` | Optional | `null \| "month" \| "week" \| "year"` | - | Active timeframe type | ##### `TimeframeControlsDateRange` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `end` | Required | `number` | - | Range end (epoch ms); matched with `start` against calendar weeks to restore a week selection | | `start` | Required | `number` | - | Range start (epoch ms); the year/month/week pickers sync to this date | ### `TimeframeWeekFlatContent` Flat list of selectable week items for the [**`TimeframeControls`**](/reference/ui/components/filters/#timeframecontrols) week selector `agent-ready`
Source [src/domain/components/timeframe/timeframe-controls/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/timeframe/timeframe-controls/USAGE.md) [src/domain/components/timeframe/timeframe-controls/timeframe-controls-week-content.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/timeframe/timeframe-controls/timeframe-controls-week-content.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `visibleWeeks` | Required | `Week[]` | - | - | ##### `Week` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `bucketMonth` | Required | `number` | - | Month (0-11) of the week's end date in the selected timezone; sent as `month` to `onRangeChange` | | `bucketYear` | Required | `number` | - | Year of the week's end date in the selected timezone; sent as `year` to `onRangeChange` | | `end` | Required | `Date` | - | Sunday 23:59:59.999 (local time) that closes the week, capped to the end of yesterday | | `label` | Required | `string` | - | Option text, `DD/MM - DD/MM` in the selected timezone | | `start` | Required | `Date` | - | Monday 00:00:00.000 (local time) that opens the week | | `disabled` | Optional | `boolean` | - | True when the week starts after the end of yesterday; the option is shown but not selectable | ### `TimeframeWeekTreeContent` Hierarchical year → month → week tree for the [**`TimeframeControls`**](/reference/ui/components/filters/#timeframecontrols) week selector `agent-ready`
Source [src/domain/components/timeframe/timeframe-controls/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/timeframe/timeframe-controls/USAGE.md) [src/domain/components/timeframe/timeframe-controls/timeframe-controls-week-content.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/timeframe/timeframe-controls/timeframe-controls-week-content.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `selectedMonth` | Required | `number` | - | - | | `selectedYear` | Required | `number` | - | - | | `timezone` | Required | `string` | - | - | # Form (/reference/ui/components/forms) Form components for building data entry interfaces. ## Prerequisites - Install the packages ```bash npm install \ @tetherto/mdk-react-devkit \ @tetherto/mdk-react-adapter \ @tetherto/mdk-ui-foundation ``` ```bash # Clone the MDK UI monorepo (adjust the URL to your fork if needed) git clone https://github.com/tetherto/mdk.git cd mdk/ui # Install dependencies and build packages (npm workspaces) npm install npm run build ``` Then add the dependency to your app's `package.json`: ```json { "dependencies": { "@tetherto/mdk-react-devkit": "*", "@tetherto/mdk-react-adapter": "*", "@tetherto/mdk-ui-foundation": "*" } } ``` Run `npm install` from the `mdk/ui` workspace root after your app is under `apps/` so npm links workspace packages. - Import styles: `import '@tetherto/mdk-react-devkit/styles.css'` ## Components @tetherto/mdk-react-devkit ### Core form workflow ```tsx ``` Form primitives built on [`react-hook-form`](https://react-hook-form.com/). Use with a `useForm()` instance. #### Pieces - `Form` — wraps a `
` and provides `FormProvider` context. - `FormField` — wraps [`react-hook-form`](https://react-hook-form.com/)'s `Controller` and provides field context. - `FormItem` — layout wrapper that generates IDs for accessibility linking. - `FormLabel`, `FormControl`, `FormDescription`, `FormMessage` — slots. #### Notes - Pre-built field helpers (`FormInput`, `FormSelect`, `FormCheckbox`, `FormDatePicker`, `FormCascader`, etc.) live in [`form-fields.tsx`](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/form-fields.tsx). - See the directory's [`README.md`](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/README.md) and [`QUICK_REFERENCE.md`](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/QUICK_REFERENCE.md) for the full pattern catalog. #### Example [#core-workflow-example] ```tsx /** * Runnable example for Form (react-hook-form + zod). */ Button, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Input, } from '@tetherto/mdk-react-devkit' const schema = z.object({ name: z.string().min(2, 'At least 2 characters'), email: z.string().email('Must be a valid email'), }) type FormValues = z.infer const form = useForm({ resolver: zodResolver(schema), defaultValues: { name: '', email: '' }, }) const onSubmit = (values: FormValues) => { // oxlint-disable-next-line no-console console.log('submit', values) } return ( ( Name )} /> ( Email )} /> ) } ``` #### Related API - [**`useFormField`**](/reference/ui/hooks/forms/#useformfield) @tetherto/mdk-react-devkit ### Pre-built input field ```tsx ``` #### Related API - [**`FormField`**](/reference/ui/components/forms/#formfield) - [**`useFormField`**](/reference/ui/hooks/forms/#useformfield) @tetherto/mdk-react-devkit ### Date range picker ```tsx ``` Single-date and range-date pickers built on `react-day-picker`. The range picker includes presets and a modal-style popover with Clear / Apply actions. #### Data contracts ```ts type DateRange = { from: Date | undefined; to?: Date | undefined }; type PresetItem = { label: string; value: DateRange }; ``` #### Example [#date-range-picker-example] ```tsx /** * Runnable example for DatePicker and DateRangePicker. */ const [date, setDate] = useState() const [range, setRange] = useState() return (
) } ``` @tetherto/mdk-react-devkit Import the public APIs on this page from [`@tetherto/mdk-react-devkit`](/reference/ui/#tethertomdk-react-devkit). ### `Cascader` Two-panel hierarchical selector for picking a leaf value from a nested tree (categories → subcategories → leaf). Features: - Two-column layout: categories on left, options on right - Single or multiple selection modes - Search/filter functionality via [**`TagInput`**](/reference/ui/components/forms/#taginput) - Category-level selection (select/deselect all children) - Indeterminate state for partial selections - [**`Tag`**](/reference/ui/components/display/#tag) display for multiple selections - Keyboard navigation support - Disabled state support `agent-ready`
Source [src/primitives/components/cascader/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/cascader/USAGE.md#props-detail) [src/primitives/components/cascader/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/cascader/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `options` | Required | `CascaderOption[]` | - | Hierarchical options to display in the cascader Parent options with children appear in the left panel Child options appear in the right panel when parent is selected | | `className` | Optional | `string` | - | Custom className for the root cascader element | | `disabled` | Optional | `boolean` | `false` | Disable the entire cascader (input and all options) | | `dropdownClassName` | Optional | `string` | - | Custom className for the dropdown panels container | | `multiple` | Optional | `boolean` | `false` | Enable multiple selection mode - true: Shows checkboxes, allows multiple selections, displays selected items as tags - false: Shows radio buttons, allows single selection | | `onChange` | Optional | `((value: CascaderValue \| CascaderValue[] \| null) => void)` | - | Callback when selection changes - For single select: receives CascaderValue or null - For multiple select: receives CascaderValue[] or null | | `placeholder` | Optional | `string` | `"Select..."` | Placeholder text shown in the input when no selections are made | | `value` | Optional | `CascaderValue \| CascaderValue[]` | - | Current selected value(s) - For single select: CascaderValue (e.g., ['category', 'option']) - For multiple select: CascaderValue[] (e.g., [['cat1', 'opt1'], ['cat2', 'opt2']]) | ##### `CascaderOption` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `label` | Required | `string` | - | Display label for the option | | `value` | Required | `string \| number \| boolean` | - | Unique value for the option | | `children` | Optional | `CascaderOption[]` | - | Child options (creates a nested category) | | `disabled` | Optional | `boolean` | - | Whether the option is disabled | ### `Checkbox` [**`Checkbox`**](/reference/ui/components/forms/#checkbox) component with full customization `agent-ready`
Source [src/primitives/components/checkbox/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/checkbox/USAGE.md) [src/primitives/components/checkbox/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/checkbox/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `checked` | Optional | `CheckedState` | - | Controlled checked state | | `className` | Optional | `string` | - | Custom className for the root element | | `color` | Optional | `"success" \| "warning" \| "error" \| "primary" \| "default"` | `"primary"` | Color variant when checked | | `defaultChecked` | Optional | `CheckedState` | - | Uncontrolled initial checked state | | `disabled` | Optional | `boolean` | `false` | Disable the input | | `indicatorClassName` | Optional | `string` | - | Custom className for the indicator element | | `onCheckedChange` | Optional | `(((checked: CheckedState) => void) & ((checked: CheckedState) => void))` | - | Callback when the checked state changes | | `radius` | Optional | `"small" \| "none" \| "medium" \| "large" \| "full"` | `"none"` | Border radius variant | | `size` | Optional | `"sm" \| "md" \| "lg" \| "xs"` | `"md"` | Size variant of the checkbox | ### `CurrencyToggler` [**`CurrencyToggler`**](/reference/ui/components/forms/#currencytoggler) component for switching between currencies `agent-ready`
Source [src/primitives/components/currency-toggler/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/currency-toggler/USAGE.md) [src/primitives/components/currency-toggler/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/currency-toggler/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `currencies` | Required | `(string \| CurrencyItem)[]` | - | List of currency options | | `onChange` | Required | `(currency: string) => void` | - | Fired with the selected currency value when a button is clicked | | `value` | Required | `string` | - | Currently selected currency value | | `className` | Optional | `string` | - | Additional class for the root element | ##### `CurrencyItem` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `value` | Required | `string` | - | Option value (used for selection) | | `disabled` | Optional | `boolean` | - | Disables this option | | `label` | Optional | `string` | - | Display label (defaults to `value`) | ### `DatePicker` Single-date selection component built on react-day-picker with the MDK dark theme. Controlled via `selected` / `onSelect` `agent-ready`
Source [src/primitives/components/date-picker/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/date-picker/USAGE.md) [src/primitives/components/date-picker/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/date-picker/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `calendarClassName` | Optional | `string` | - | Custom className for the calendar | | `dateFormat` | Optional | `string` | `"MM/dd/yyyy"` | Date format for display | | `disabled` | Optional | `(boolean & (Matcher \| Matcher[]))` | `false` | Whether the picker is disabled | | `onSelect` | Optional | `((date: Date \| undefined) => void)` | - | Callback when date changes | | `placeholder` | Optional | `string` | `"Pick a date"` | Placeholder text when no date is selected | | `selected` | Optional | `Date` | - | Currently selected date | | `triggerClassName` | Optional | `string` | - | Custom className for the trigger button | ### `DateRangePicker` Date-range selection component with preset shortcuts (last 7/14/30/90 days) and a modal interface. Controlled via `selected` / `onSelect` `agent-ready`
Source [src/primitives/components/date-picker/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/date-picker/USAGE.md) [src/primitives/components/date-picker/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/date-picker/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `allowFutureDates` | Optional | `boolean` | `false` | Whether to allow future dates | | `calendarClassName` | Optional | `string` | - | Custom className for the calendar | | `dateFormat` | Optional | `string` | `"MM/dd/yyyy"` | Date format for display | | `disabled` | Optional | `(boolean & (Matcher \| Matcher[]))` | `false` | Whether the picker is disabled | | `modalClassName` | Optional | `string` | - | Custom className for the modal | | `onSelect` | Optional | `((range: DateRange \| undefined) => void)` | - | Callback when date range changes | | `placeholder` | Optional | `string` | `"Pick a date range"` | Placeholder text when no range is selected | | `presets` | Optional | `PresetItem[]` | - | Custom preset items | | `selected` | Optional | `DateRange` | - | Selected date range | | `showPresets` | Optional | `boolean` | `true` | Whether to show preset buttons | | `triggerClassName` | Optional | `string` | - | Custom className for the trigger button | ##### `DateRange` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `from` | Required | `Date` | - | Range start; `undefined` means nothing is selected and the placeholder shows | | `to` | Optional | `Date` | - | Range end; while unset the trigger shows `from` alone and Apply stays disabled | ##### `PresetItem` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `label` | Required | `string` | - | Preset button text; also used as the React key, so keep it unique | | `value` | Required | `DateRange` | - | Range applied to the draft on click; needs both `from` and `to`. Highlighted when the draft is within 5 minutes of it at both ends | ### `Form` React Hook [**`Form`**](/reference/ui/components/forms/#form) provider wrapper. Pass the result of `useForm()` as `form` and render fields via [`FormField`](/reference/ui/components/forms/#formfield) / [`FormItem`](/reference/ui/components/forms/#formitem) / [`FormLabel`](/reference/ui/components/forms/#formlabel) / [`FormControl`](/reference/ui/components/forms/#formcontrol) / [`FormMessage`](/reference/ui/components/forms/#formmessage) `agent-ready`
Source [src/primitives/components/form/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/USAGE.md) [src/primitives/components/form/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `children` | Required | `React.ReactNode` | - | - | | `form` | Required | `UseFormReturn` | - | - | ### `FormCascader` Pre-built [**`Cascader`**](/reference/ui/components/forms/#cascader) field component with integrated form state. Perfect for hierarchical selections like categories and subcategories `agent-ready`
Source [src/primitives/components/form/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/USAGE.md) [src/primitives/components/form/form-fields.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/form-fields.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `options` | Required | `CascaderOption[]` | - | - | | `cascaderProps` | Optional | `Omit, "onChange" \| "value" \| "options" \| "placeholder">` | - | - | | `description` | Optional | `string` | - | - | | `label` | Optional | `string` | - | - | | `multiple` | Optional | `boolean` | - | - | | `placeholder` | Optional | `string` | - | - | ### `FormCheckbox` Pre-built [**`Checkbox`**](/reference/ui/components/forms/#checkbox) field component with integrated form state `agent-ready`
Source [src/primitives/components/form/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/USAGE.md) [src/primitives/components/form/form-fields.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/form-fields.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `checkboxProps` | Optional | `({ checked?: CheckedState; defaultChecked?: CheckedState; disabled?: boolean; size?: CheckboxSize; color?: ComponentColor; radius?: BorderRadius; className?: string; indicatorClassName?: string; onCheckedChange?: ((checked: CheckedState) =>… /* see source */` | - | - | | `description` | Optional | `string` | - | - | | `label` | Optional | `string` | - | - | | `layout` | Optional | `"row" \| "column"` | - | - | | `placeholder` | Optional | `string` | - | - | ### `FormControl` Slot-based wrapper that injects ARIA attributes onto its child input element without adding an extra DOM wrapper `agent-ready`
Source [src/primitives/components/form/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/USAGE.md) [src/primitives/components/form/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/index.tsx)
### `FormDatePicker` Pre-built [**`DatePicker`**](/reference/ui/components/forms/#datepicker) field component with integrated form state `agent-ready`
Source [src/primitives/components/form/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/USAGE.md) [src/primitives/components/form/form-fields.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/form-fields.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `datePickerProps` | Optional | `object` | - | - | | `description` | Optional | `string` | - | - | | `label` | Optional | `string` | - | - | | `placeholder` | Optional | `string` | - | - | ### `FormDescription` Optional helper text displayed below the input `agent-ready`
Source [src/primitives/components/form/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/USAGE.md) [src/primitives/components/form/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/index.tsx)
### `FormField` Wraps react-hook-form's Controller and provides field context to descendants `agent-ready`
Source [src/primitives/components/form/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/USAGE.md) [src/primitives/components/form/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/index.tsx)
### `FormInput` Pre-built [**`Input`**](/reference/ui/components/forms/#input) field component with integrated form state. Reduces boilerplate by combining [**`FormField`**](/reference/ui/components/forms/#formfield), [**`FormItem`**](/reference/ui/components/forms/#formitem), [**`FormLabel`**](/reference/ui/components/forms/#formlabel), [**`FormControl`**](/reference/ui/components/forms/#formcontrol), and [**`FormMessage`**](/reference/ui/components/forms/#formmessage) `agent-ready`
Source [src/primitives/components/form/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/USAGE.md) [src/primitives/components/form/form-fields.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/form-fields.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `description` | Optional | `string` | - | - | | `inputProps` | Optional | `Omit & React.RefAttributes, "type" \| "variant">` | - | - | | `label` | Optional | `string` | - | - | | `placeholder` | Optional | `string` | - | - | | `type` | Optional | `React.HTMLInputTypeAttribute` | - | - | | `variant` | Optional | `"search" \| "default"` | - | - | ### `FormItem` Layout wrapper for a form field. Generates a unique ID for accessibility linking `agent-ready`
Source [src/primitives/components/form/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/USAGE.md) [src/primitives/components/form/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/index.tsx)
### `FormLabel` [**`Label`**](/reference/ui/components/forms/#label) that auto-links to the form field input via generated IDs. Applies error styling when the field has a validation error `agent-ready`
Source [src/primitives/components/form/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/USAGE.md) [src/primitives/components/form/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/index.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `htmlFor` | Optional | `string` | - | Id of the input being labelled | ### `FormMessage` Displays the validation error message from react-hook-form field state. Falls back to children if no error is present. Always renders to prevent layout shift when errors appear `agent-ready`
Source [src/primitives/components/form/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/USAGE.md) [src/primitives/components/form/index.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/index.tsx)
### `FormRadioGroup` Pre-built [**`RadioGroup`**](/reference/ui/components/forms/#radiogroup) field component with integrated form state `agent-ready`
Source [src/primitives/components/form/USAGE.md](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/USAGE.md) [src/primitives/components/form/form-fields.tsx](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/primitives/components/form/form-fields.tsx)
#### Props | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `options` | Required | `FormRadioOption[]` | - | - | | `description` | Optional | `string` | - | - | | `label` | Optional | `string` | - | - | | `orientation` | Optional | `"horizontal" \| "vertical"` | - | - | | `placeholder` | Optional | `string` | - | - | | `radioGroupProps` | Optional | `object` | - | - | ##### `FormRadioOption` | Prop | Status | Type / Options | Default | Description | |------|--------|----------------|---------|-------------| | `label` | Required | `string` | - | Text of the radio's `