# 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 authentication and authorization at every tier: the Kernel issues the tokens and needs one on every request past sign-in, and the Gateway, the MCP server, and the Kernel all judge a caller against the one `spec.rbac` policy in `mdk.yaml`. 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**: [a publishable package of routes and UI](/concepts/whats-an-app), whose `mdk-plugin.json` declares its own routes and whose `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. The Gateway signs callers in and checks [a route against `spec.rbac`](/guides/gateway/apps#auth-and-permissions) before a controller runs. 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) - Compare against [measured devices-per-Worker limits and recommended operating envelopes](/guides/deployment/benchmark-results) # Security boundaries (/concepts/security-boundaries) ## TL;DR Every caller of an MDK stack has an identity issued by the Kernel's token issuer, and one policy, `spec.rbac` in `mdk.yaml`, decides what it may do. The Gateway checks users, the MCP server checks AI agents, and the Kernel checks every caller on every request, per action and per device. Transport keys only admit a process to connect; they grant nothing. Workers admit only the Kernel, so every device call passes the Kernel's checks and its write-command log. 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 what each layer of the Worker → Kernel → Gateway → UI chain authenticates and authorizes, and where the trust boundaries between them sit. Data flows outward along that chain; a request travels the other way, entering at the Gateway (or, for AI agents, the standalone MCP server). ## Identity and policy Two separate questions are answered for every request, by two separate mechanisms: | Question | Answered by | Grants | | -------- | ----------- | ------ | | May this process connect? | Transport admission: the Kernel's [caller allowlist](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#caller-admission-and-authorization) or the trusted-keys dir, a Worker's `allowedKeys` | A connection, nothing more | | May this identity do this? | An access token from the [token issuer](https://github.com/tetherto/mdk/blob/main/backend/core/identity/README.md), evaluated against `spec.rbac` by the [RBAC library](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md) | One action on one resource, per rule | The identities are: | Subject | Signs in with | Bound in `spec.rbac` as | | ------- | ------------- | ----------------------- | | A person | User name and password at the Gateway's `POST /auth/login`, or `mdk login` | `User` (name or email), or `Group` | | An app | An assertion signed by its own key, from its runtime | `ServiceAccount` `app:` (role `app-`, from its `mdk-app.yaml` grant) | | An AI agent | An assertion signed by its own key (`mdk serviceaccount add `, alias `mdk sa`) | `ServiceAccount` `` | | The CLI, Gateway and MCP server | Their own keys | `ServiceAccount` `mdk-cli`, `mdk-gateway`, `mdk-mcp` | The policy is default deny. No matching rule is a refusal, a stack with no bindings refuses every Kernel call, and there is no superadmin: `admin` is an ordinary role holding wildcards. A package never decides who may call it; an app only requests its own Kernel access, and the operator's `spec.rbac` grants it. Access tokens are EdDSA-signed JWTs that live 600 seconds, paired with a refresh token that rotates on every use. Changing a password, disabling or deleting a subject, changing a user's groups or email, or giving a service account a new key refuses the tokens it already holds: at once wherever the issuer verifies (every Kernel request, and a Gateway or MCP server in the Kernel's process), and within one policy poll (5 seconds) at a Gateway or MCP server in another process, which polls every subject's token version with the policy. ## Layer by layer | Layer | Authenticates | Authorizes | | ----- | ------------- | ---------- | | Gateway | A bearer access token on every app route; no route is open | A `spec.rbac` rule for the route's resource (`/`) and verb, and its instance when the route declares `resourceName` | | App runtime | Only the Gateway's and MCP server's keys | Nothing itself: it receives the verified identity as `req._info`, and calls Kernel as `app:` acting for the signed-in user (`req.mdk`) | | MCP server | An agent's own access token on every `POST /mcp` | Which tools the agent lists, and each call, by the same route rules the Gateway applies | | Kernel | An access token on every request but sign-in, in the envelope `auth` field; a service account's token only from the transport key it signed in with. The user an app acts for, by their own token in the envelope `actor` field | Each action against `spec.rbac`, per device (`devices` on `family/brand/worker/device`); a worker-scoped command on every device the worker owns. With an `actor`, both the app and the user must be allowed | | Worker | Only the Kernel's client key | Nothing itself: the Kernel already checked the caller | ## Operator identity at the Kernel A command request alone does not establish who the operator is: an app calls Kernel with its own service account. The Gateway closes that gap by passing the verified operator to Kernel's authorization, not just to the app: 1. The Gateway verifies the user's bearer token and checks the route's rule. 2. It forwards the request to the app runtime with the identity (`req._info`) and, beside it, the user's access token. App code never sees the token. 3. A controller calls Kernel through `req.mdk`, which sends the app's token in `auth` and the user's in `actor`. 4. Kernel verifies the user's token itself (signature, expiry, token version, a user and not a service account) and evaluates the action for both identities. Either refusal refuses the request. 5. The write-command log records the user as the caller, `via` the app, with the roles `spec.rbac` binds to each. The user is also the voter on approval-gated pushes, votes and cancels. Kernel trusts no claim it cannot verify: an app cannot name an operator, only forward a token the issuer signed. What the app holds caps what any of its users can do through it, and what the user holds caps what the app does for them. ## 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, or grant command permission. 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 A Worker is fail-closed: it answers only the keys in its `allowedKeys`, and `mdk run` and the `@tetherto/mdk-core` helpers give it the Kernel's client key and no other. Every device call therefore goes through the Kernel, where the caller's token is checked against `spec.rbac` and the attempt is written to the [write-command log](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#write-command-log). `allowAll` on `WorkerRuntime` (`MDK_WORKER_ALLOW_ALL=1`) admits every peer that learns the Worker key, with no policy check and no log entry; it exists for throwaway local experiments only, and `mdk run` never sets it. Consumers enter through the Gateway → app runtime → Kernel path, and AI agents through the MCP server → app runtime → Kernel path. [Kernel admission](/guides/security#step-2-admit-the-gateway-to-kernel) admits each path's caller by its HRPC public key, and its token decides what it may do. The surrounding controls are yours: - Restrict Worker reachability to trusted backend networks, and apply a host/container firewall policy - Never expose device management interfaces publicly. A Worker's allowlist protects the Worker, not the device behind it - 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 Every `command.request` reaches a Worker only after the Kernel has checked `devices: write` on its target (on every device of the worker, for a worker or rack scope) and validated the command and params against the Worker's contract. The write-command log records each attempt, refused ones included, with the caller's subject and the roles `spec.rbac` binds to it (for an app acting for an operator, the operator, and the app as `via`); reading it with `command.log` needs `commands: read`. Approval-gated writes check `devices: write` on every device a push resolves to, and each vote against the stored action's target devices with the voter's own rule. For a token caller, the voter is the operator it acts for, else its own identity unless a rule grants `actions: delegate`, and self-asserted `authPerms` in the payload are ignored. What stays with the handler: - Validate the request again (types, ranges, allowed targets), since the contract does not know your hardware's unsafe combinations - Rate-limit device-facing operations the vendor protocol cannot absorb The handler context does not include actor identity; the write-command log and the Gateway's `authz` decision lines are the audit trail. The control-plane security model covers the request paths end to end. 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. ## Trust boundaries and known limits | Boundary or limit | What happens | What to do | | ----------------- | ------------ | ---------- | | App routes are a confused-deputy boundary | The Gateway checks the user against the route's rule and forwards their token. A controller calling Kernel through `req.mdk` acts for that user: Kernel checks both `app:` and the user. A controller calling through `config.mdkClient` acts as the app alone, and Kernel does not re-check the user | Use `req.mdk` for work done for a request, keep each app's grant narrow (`resourceNames`), and review app controller code like any privileged service | | Policy at a remote Gateway | A Gateway or MCP server in its own process polls the policy every 5 seconds. A failed poll keeps the last good policy, so a Kernel blip does not lock the site, but for 60 seconds at most (`maxStaleMs`): past that it allows nothing until a poll succeeds. Kernel switches at once | Watch for failing polls; run the Gateway in the Kernel's process where an instant switch matters | | Revoked access tokens at the edge | A Gateway or MCP server in another process checks tokens against the token versions it polls with the policy, so a revoked token keeps working there until the next poll (up to 5 seconds). Kernel refuses it at once | Treat the edge window as one poll interval | | Login lockout is in memory | Failed sign-ins lock a user name in the Kernel's memory; a Kernel restart clears the count | Rate-limit `/auth/login` at the proxy too | | Refresh cookie over plain HTTP | The refresh cookie is `Secure` everywhere but plain-HTTP loopback, so over plain HTTP on a network the browser keeps no refresh cookie | Serve the Gateway over TLS, or behind a TLS proxy with `trustProxy` set | ## Next steps - [Site security blueprint](/guides/security): steps, options, and suggested defaults for a real site - [RBAC policy model](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md): rules, subjects, and the resource vocabulary - [Token issuer](https://github.com/tetherto/mdk/blob/main/backend/core/identity/README.md): tokens, sessions, and when they stop working - [Gateway security model](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md#security-model): sign-in routes and the per-route check - Control-plane security model: the request paths for reads, direct commands, and approval-gated writes - [Worker Runtime store services](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: | Aspect | 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` | The legacy rows describe the thing and adapter surface (`createWorkerInfra`), which every in-repo Worker uses and which is deprecated; 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 # HRPC and direct RPC compared (/concepts/transport-comparison) ## TL;DR On an open connection, HRPC and a direct TCP RPC cost about the same: tens of microseconds for a small call on Linux loopback. The DHT shows up around that call: joining the network, the first call to a peer, how long a failure takes to surface, and how little the error says. A site with fixed hosts and internal DNS finds a known-endpoint RPC simpler to run and to diagnose. HRPC pays off where addresses change, peers sit behind NAT, or nobody wants to run a PKI. MDK ships HRPC only. ## Overview This page compares [HRPC](/reference/glossary#hyperswarm-rpc) over HyperDHT, the transport on every MDK hop, with a direct RPC that dials a known host and port and authenticates both ends as strongly. It covers setup effort, failure modes, latency and diagnosability, then when the DHT is most valuable. MDK has no known-endpoint transport to switch to; the comparison helps choose a deployment and set expectations with a network team. How Kernel learns a Worker key is a separate question, answered by the deployment topologies. Two direct baselines are measured: - HTTPS with mutual TLS and a private CA, the usual way to authenticate a direct RPC - Noise over TCP: the handshake and RPC framing HRPC already uses (`@hyperswarm/secret-stream`, `protomux-rpc`) with pinned keys, dialed at `host:port`. A known-endpoint MDK transport would most likely take this shape. ## What differs | Property | HRPC over HyperDHT | Direct RPC with equivalent authentication | | ---------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------- | | Address | The peer's 32-byte public key | `host:port`, an IP or an internal DNS name | | Identity | The address itself, an Ed25519 key pair derived from a seed kept in the component's store | A certificate from your CA, or a pinned Noise key, separate from the address | | Connection setup | DHT lookup of the key, handshake relayed by DHT nodes, then a direct UDP stream, holepunched when needed | TCP connect, then the TLS or Noise handshake | | Listener ports | None fixed; UDP on ephemeral ports | One TCP port per service | | Network needs | UDP to the bootstrap nodes, public or private, and to every peer | TCP to each endpoint, plus DNS when names are used | | Admission | Key allowlists: the Kernel caller allowlist, `discovery.workerKeys`, Worker `allowedKeys` | CA trust plus a subject or key allowlist | | NAT | Peers behind NAT reach each other with no inbound rule | The server needs a reachable address and an inbound rule | ## Setup effort | Task | HRPC over HyperDHT | Direct RPC | | ---------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------- | | Give each service an address | Nothing: the key comes from the stored seed | Assign a static IP or DNS name and keep config in step with it | | Distribute credentials | Share public keys: the Kernel key file, `.worker-keys`, or pinned lists | Run a CA, issue a certificate per service and rotate it before expiry, or pin keys | | Admit callers | List keys on each side | Trust the CA and match subjects, or pin keys | | Open the network | UDP between backend hosts and to the bootstrap nodes; no per-service rule | One TCP rule per service port | | Run with no outside network | Run a [private bootstrap](/guides/deployment/private-dht) every component joins: from code, `spec.network.dht` in `mdk.yaml` or `startServices({ dht })` | Nothing beyond the network itself | | Run on one host | Still joins a DHT, so still needs bootstrap reach | Loopback, nothing else | The [hosted-site reference](/guides/deployment/hosted-site) lists every key, allowlist and bootstrap setting a managed site sets. ## Failure modes Each row is a measured drill against a peer on the same host, on a three-node private DHT unless the row names the public DHT. The [method](#method) section describes the drills. | Failure | HRPC over HyperDHT | HTTPS with mutual TLS | | ------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------- | | Peer process killed, new connection | `CHANNEL_CLOSED` after 4.0 s | `ECONNREFUSED` after about 1 ms | | Peer process killed, open connection | `CHANNEL_CLOSED` after 13 s | `ECONNREFUSED` after about 1 ms | | Peer process frozen with `SIGSTOP` | `CHANNEL_CLOSED` after 4.0 s on a new connection, 13 s on an open one | No error within 60 s; only the caller's timeout ends the call | | Peer closed cleanly | `CHANNEL_CLOSED` within 4 ms; 2.4 s on the public DHT | `ECONNREFUSED` after about 1 ms | | Key or address that never existed | `CHANNEL_CLOSED` within 3 ms; 2.2 s on the public DHT | `ECONNREFUSED`, or `ENOTFOUND` for an unknown name | | Caller missing from the allowlist | `CHANNEL_CLOSED` after 4.0 s; 9.0 s on the public DHT | `ECONNRESET` after about 4 ms | | Bootstrap nodes unreachable | Joining the DHT and `listen()` resolve after about 6 s with no error; a call then fails with `CHANNEL_CLOSED` after 4.0 s | Not applicable | | One call outlives the 10 s pool linger | `CHANNEL_DESTROYED` at 10 s when no other request to that peer started meanwhile | Not applicable | HRPC fails closed in bounded time even when the caller sets no timeout, because HyperDHT gives up on a peer that stops answering; plain TCP does not. The price is that every failure is the same `CHANNEL_CLOSED`, seconds rather than milliseconds later, so a dead peer, a wrong key and a missing allowlist entry look identical to the caller. Noise over TCP fails a closed port or a rejected key within 2 ms, but also as `CHANNEL_CLOSED`, because `protomux-rpc` reports every closed stream that way; a known-endpoint transport keeps TCP's diagnosability only if it passes the socket error through. MDK adds its own bounds on top: - An MDK client request times out after 30 s by default with `ERR_MDK_REQUEST_TIMEOUT`, and a Gateway app built on `createMdkClient` sees a transport failure as `ERR_MDK_CLIENT_UNAVAILABLE: CHANNEL_CLOSED: channel closed` - Kernel calls to a Worker time out after 30 s with `ERR_CHANNEL_TIMEOUT`, and the Kernel [health monitor](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#healthmonitor) pings each Worker every 5 s with a 3 s timeout; the first missed ping marks it `SICK`, which stops command dispatch to it - The 10 s pool linger belongs to `@hyperswarm/rpc`'s pooled `request()`, which the Gateway's Kernel client and Kernel's Worker calls use; the app runtime client holds its connection open while a call is in flight, so Gateway calls to apps are not cut ## Latency Measured on Linux loopback, one request at a time. p50 and p99 come from 1,000 calls for 256 B and 200 calls for 64 KiB; the first call opens a new connection to an announced peer, 20 times. | Step | HRPC, private DHT | Noise over TCP | HTTPS with mutual TLS | | --------------------------------- | ------------------ | ------------------ | --------------------- | | Join the network | About 1 ms | Not applicable | Not applicable | | Announce a server | 1.6 ms | Not applicable | Not applicable | | First call to a peer, p50 and max | 4.8 ms, 7.7 ms | 0.84 ms, 5.3 ms | 1.8 ms, 6.3 ms | | 256 B call, p50 and p99 | 0.024 ms, 0.11 ms | 0.017 ms, 0.082 ms | 0.13 ms, 0.42 ms | | 64 KiB reply, p50 and p99 | 0.41 ms, 5.1 ms | 0.27 ms, 0.52 ms | 0.17 ms, 0.49 ms | HRPC replies between 16 and 64 KiB stall now and then for 3 to 6 ms; a 1 MiB reply measured 6.5 ms p50. On the macOS host every HRPC call on an open connection is slower: 0.44 ms for 256 B and 10 ms for 64 KiB, growing linearly to 155 ms for 1 MiB, against 0.05 ms and 0.28 ms for Noise over TCP on the same machine. Benchmark on the OS the site runs, not on a developer laptop. The public DHT changes the steps around the call, not the call. From the macOS host, joining took 6.2 to 6.8 s, announcing 3.6 s, and a first call to a peer 527 ms p50 with a 1.39 s maximum; calls on an open connection matched the private DHT. A component that restarts on the public DHT is therefore unreachable for about 10 s, and every first contact with a peer costs about half a second. At MDK's cadences (telemetry pulls every 10 s, health pings every 5 s) per-call cost does not decide between the two. Start-up, reconnects and failure detection do. ## Diagnosability | Question | HRPC over HyperDHT | Direct RPC | | ------------------------- | -------------------------------------------------------------------------------------- | --------------------------------- | | Is the service up? | No port to probe; only an HRPC call to its key answers, and Kernel's health states cover Workers | `nc`, `curl`, load balancer health checks and blackbox probes against `host:port` | | Where is it? | Resolved from the key at connect time, never written in config | Config and DNS | | Why did a call fail? | `CHANNEL_CLOSED` for a dead peer, a frozen peer, a wrong key, a missing allowlist entry and unreachable bootstrap alike; only the delay differs | `ECONNREFUSED`, `ECONNRESET` and `ENOTFOUND` point at different causes | | Who was rejected? | Only the rejecting side knows: `DEBUG=mdk:kernel:hrpc` logs `blocked: ` on Kernel, `DEBUG=mdk:worker:runtime` logs `blocked caller` on a Worker | The server rejects the certificate during the TLS handshake and can log its subject | | What is on the wire? | Encrypted UDP between ephemeral ports; a capture shows flows, not services | TCP to a known port that capture tools label by port and TLS server name | | Existing tooling | Needs MDK-aware checks | Standard monitoring, load balancers and service meshes work unchanged | ## When the DHT is most valuable HRPC over HyperDHT earns its keep when: - Addresses change: DHCP leases, rescheduled containers or hosts that move, so config must never name an IP - Peers sit behind NAT or a firewall with no inbound rule, for example a Gateway outside the site network dialing its Kernel - Nobody wants to run a PKI: keys need no CA, no issuance and no expiry - Identity must outlive the address: allowlists name keys, so renumbering a network changes nothing - One configuration must hold from a laptop to a multi-host site A known-endpoint RPC is simpler when: - Hosts are fixed and internal DNS already names them - Outbound UDP to the public bootstrap nodes is blocked, so HRPC needs a private bootstrap anyway - Operations depend on port probes, load balancer health checks or packet inspection - A PKI or service mesh already issues and rotates certificates - A failure must surface in milliseconds with a specific cause A managed site that runs MDK today keeps HRPC and narrows its cost: pin every key, prefer local or single-process discovery where Kernel and its Workers share a host, and run a private bootstrap where outbound UDP is blocked. The locked-down managed site shape covers those choices. ## Method - One host, loopback only, so the numbers isolate transport overhead and leave out network round trips. They compare transports; they are not capacity figures. - Linux is Node 24.22 in a `node:24-bookworm-slim` arm64 container under OrbStack on an Apple M3 Max; macOS is Node 24.16 on the same machine. - HRPC is `@hyperswarm/rpc` 3.5.0 on `hyperdht` 6.34.1, the versions MDK pins. The private DHT is three `hyperdht/testnet` nodes on loopback; the public DHT uses the default bootstrap set. - Each call sends a 256 B request, after 50 warm-up calls on the same connection. - Failure drills run the peer as a separate process, send `SIGKILL` or `SIGSTOP` 200 ms before the call, and cap each wait at 60 s. The kill and freeze drills ran on macOS; the others ran on both hosts, and their timings, set by protocol timers, matched. ## Next steps - Deployment topologies: pick how Kernel learns Worker keys before choosing a network design - [Hosted-site reference configuration](/guides/deployment/hosted-site): apply the key, allowlist and bootstrap settings for a managed site - [Benchmark your site](/guides/deployment/benchmark-your-site): measure Kernel, Gateway and Workers on your own hardware # 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`, `permissions` (`[]` for none), and optional `description`, `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` | [`mdk-app-demo`](https://github.com/tetherto/mdk-app-demo) 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) # 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-app-agent) 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#command-groups) and select [`mdk-app-agent`](https://github.com/tetherto/mdk-app-agent) from its app catalog. `mdk onboard`'s wizard supports creating a compliant `mdk.yaml` from scratch, interactively. It installs `@tetherto/mdk-app-agent` by package name and writes its `spec.apps` entry with `mount: /`. Until `@tetherto/mdk-app-agent` is on npm, install it from [its repository](https://github.com/tetherto/mdk-app-agent) with `mdk add app github:tetherto/mdk-app-agent`, which writes the same `spec.apps` entry, `mount: /` included. #### 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`. The app resolves from `node_modules/@tetherto/mdk-app-agent`. 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 rbac: enforcement: deny roles: - name: agent-operator rules: - resources: [agent/*] verbs: [get, list, create, update, delete] bindings: - role: agent-operator subjects: [{ kind: ServiceAccount, name: operator-agent }] ``` `spec.rbac` is what lets a caller reach the app's routes, and a stack without one [denies every route](/guides/gateway/apps#auth-and-permissions). A route is governed as `/`, so `agent/*` covers everything this app serves whatever its paths are. To name routes individually instead, take them from [the app's own route table](https://github.com/tetherto/mdk-app-agent) and write them as [resources and verbs](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md#vocabulary) inside [a role and its bindings](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md#policy). `mdk rbac apply` hands an [edited policy](https://github.com/tetherto/mdk/blob/main/packages/cli/README.md#access-policy) to a Kernel that is already running, and a binding's subject is [a user or a service account you create](https://github.com/tetherto/mdk/blob/main/packages/cli/README.md#users-and-service-accounts). 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: ```bash mdk sa add operator-agent # the credential the caller below signs in with; the Kernel registers it at the next start mdk rbac init # binds the CLI's own account and grants the app the Kernel access its manifest asks for mdk run ``` `mdk run` refuses to boot a Kernel that nobody can administer, so without `mdk rbac init` it exits with `spec.rbac binds nobody`. `mdk run` then 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. Every agent route needs a bearer token, local caller or not, and `mdk run` refuses a `gateway.config.authVerifier`. A person signs in at `/auth/login` with a user name and password, which [`mdk user add`](https://github.com/tetherto/mdk/blob/main/packages/cli/README.md#users-and-service-accounts) creates; the non-interactive caller below signs in as the `operator-agent` service account the policy binds (Gateway security model). A missing [`config.agent`](https://github.com/tetherto/mdk-app-agent#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 TOKEN=$(mdk sa token operator-agent) curl -X POST http://localhost:/agent/sessions \ -H "Authorization: Bearer $TOKEN" # {"sessionId":"..."} curl -N -X POST http://localhost:/agent/sessions//messages \ -H "Authorization: Bearer $TOKEN" \ -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 "Authorization: Bearer $TOKEN" \ -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-app-agent#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-app-agent#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-app-agent): 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: - **macOS:** Metal - **Linux and Windows:** Requires **Vulkan 1.4+**. On macOS and Linux, a missing GPU backend falls back to CPU (slower but functional). On Windows, the native runtime requires Vulkan 1.4+ even for CPU inference and fails outright if missing or on an older version. Verify with `vulkaninfo --summary` before running. - 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 on port `11500` ### 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 Install the QVAC CLI QVAC installs into a separate directory to keep the repository's dependencies and lockfile clean: ```bash mkdir -p ~/qvac-runtime && cd ~/qvac-runtime && npm init -y # on macOS/Linux # On Windows (PowerShell): md ~/qvac-runtime; cd ~/qvac-runtime; npm init -y npm i @qvac/cli@^0.12.0 # ~6 GB of prebuilt engines ```
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.
**Optional: Free ~2.5 to 3.4 GB of disk space** `@qvac/cli` bundles native binaries for multiple operating systems and architectures. If disk space is tight, remove the platforms you do not need: - **macOS:** ```bash rm -rf node_modules/@qvac/*/prebuilds/{win32,linux,android,ios}* ``` - **Linux (x64):** ```bash rm -rf node_modules/@qvac/*/prebuilds/{win32,darwin,android,ios,linux-arm64}* ``` - **Windows (PowerShell):** ```powershell Remove-Item "node_modules/@qvac/*/prebuilds/*" -Exclude "win32*" -Recurse -Force ``` *(Note: This is disk relief rather than a permanent fix: running `npm install` again will restore them.)* #### 1.2 Copy the model configuration From the repository root, copy the pre-configured model definition ([`qvac.config.json`](https://github.com/tetherto/mdk/blob/main/backend/core/agent/qvac-runtime/qvac.config.json)) into your runtime directory: ```bash cp backend/core/agent/qvac-runtime/qvac.config.json ~/qvac-runtime/ ``` #### 1.3 Start the model server Confirm your host capabilities and start the server: ```bash cd ~/qvac-runtime npx qvac doctor # confirms GPU backend and host capabilities # --config pins the model: Qwen3-4B, 4-bit quantized, 16k context npx qvac serve openai \ --config qvac.config.json \ --port 11500 --verbose --no-cancel-load-on-disconnect # --verbose: load + GPU offload; wait until listening on 11500 ``` The port and flag above are not 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). From the repository root, 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, navigate to `backend/core/agent` from the repository root and start the CLI, pointing it at the model (port `11500`) and the MCP tool server (port `3101`): ```bash cd backend/core/agent node bin/mdk-agent.js --model qwen3-4b --base-url http://127.0.0.1:11500/v1 --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 When inside `~/qvac-runtime`, `npx qvac` resolves from the local `./node_modules/.bin/qvac` binary. If run outside that directory, `npx` searches the npm registry for a package named `qvac` (which does not exist; the package is `@qvac/cli`), failing with a 404. Run `cd ~/qvac-runtime` first. ### `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.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 weights, and a KV cache that grows over time. Of the CLI's ~6 GB, ~4.6 GB consists of native prebuilt binaries across multiple platforms and architectures. Deleting the foreign prebuilds reclaims ~2.5 to 3.4 GB safely — see [Free foreign prebuilds in Step 1.1](#11-install-the-qvac-cli) for the commands. ### 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 The groups match the headings `mdk --help` prints: | Group | Commands | | ------- | ----------------------------------------------------------------------------------------------------- | | Set up | `mdk onboard` | | Build | `mdk create worker\|app\|dashboard`, `mdk add app\|worker `, `mdk validate app\|worker ` | | Run | `mdk run [target]`, `mdk status` | | Auth | `mdk rbac init\|apply\|show`, `mdk user`, `mdk serviceaccount`, `mdk login`, `mdk logout`, `mdk whoami` | | Other | `mdk skill add`, `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 ``` Updating a source checkout (option B above) instead: `git pull && npm install && npm run build`. ## 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 Workers catalogue](/reference/supported-workers). 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, or a [private bootstrap](/guides/deployment/private-dht) on an air-gapped machine 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 the [Supported Workers catalogue](/reference/supported-workers) - 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, push a `registerThing` write action through the Kernel instead. The Kernel routes it to the Worker named in `params[0].workerId` and runs it once the action has the approvals that Worker requires: ```js const { createMdkClient } = require('@tetherto/mdk-client') const { name, privateKeyPem } = require('./.mdk/credentials/agents/provisioner.json') const client = createMdkClient({ kernelKey: kernel.getPublicKey(), kernelCredential: { name, privateKey: privateKeyPem } }) await client.connect() await client.pushAction({ query: {}, action: 'registerThing', voter: 'provisioner', authPerms: ['container:w'], params: [{ workerId: 'antspace-rack-1', id: 'HK3-B', info: { serialNum: 'HK3-B', container: 'container-A' }, opts: { address: '192.168.1.101', port: 18001 } }] }) ``` `registerThing` is a write action, so the Kernel evaluates it against `spec.rbac` before any Worker sees it. `mdk sa add provisioner` writes the credential this reads, and the account needs `actions: write` to push, plus `devices: write`. [Provisioning a device](/guides/security#provisioning-a-device) covers how to scope that grant and why a successful push is not yet a live device. `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, push a `registerThing` write action through the Kernel instead. The Kernel routes it to the Worker named in `params[0].workerId` and runs it once the action has the approvals that Worker requires: ```js const { createMdkClient } = require('@tetherto/mdk-client') const { name, privateKeyPem } = require('./.mdk/credentials/agents/provisioner.json') const client = createMdkClient({ kernelKey: kernel.getPublicKey(), kernelCredential: { name, privateKey: privateKeyPem } }) await client.connect() await client.pushAction({ query: {}, action: 'registerThing', voter: 'provisioner', authPerms: ['container:w'], params: [{ workerId: 'bitdeer-rack-1', id: 'D40-M56-002', info: { serialNum: 'D40-M56-002', container: 'container-A' }, opts: { containerId: 'D40-M56-002' } }] }) ``` `registerThing` is a write action, so the Kernel evaluates it against `spec.rbac` before any Worker sees it. `mdk sa add provisioner` writes the credential this reads, and the account needs `actions: write` to push, plus `devices: write`. [Provisioning a device](/guides/security#provisioning-a-device) covers how to scope that grant and why a successful push is not yet a live device. `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 - Keep every process off the public HyperDHT with the [local and air-gapped profile](/guides/deployment/private-dht): private bootstrap nodes and an explicit bind interface, on one machine, an isolated LAN or Docker ## 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) - Check [published benchmark results, measured limits, and recommended operating envelopes](/guides/deployment/benchmark-results) before sizing a 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 results and operating limits (/guides/deployment/benchmark-results) ## TL;DR - One Worker passed every threshold at 2,000 simulated devices and failed at 5,000: its collection cycle took 12.4 s against a 10 s interval and its process ran at 108% of one core. Run up to 1,000 devices per Worker until you have measured your own - One Kernel served 2,000 devices across 4 Workers with every threshold green. At 5,000 devices across 5 Workers one run was green and the other failed on commands while its reads kept working: it rejected 2,105 action submits with `ERR_WORKER_NOT_ROUTABLE` and took 2,591 ms at p99 to accept the rest - A Kernel accepts a single command in about 3 to 4 ms, but under 1,000 submits per second acceptance took tens to hundreds of milliseconds at p50 from 1,000 devices up - A Worker or Kernel restart reached READY in 3.4 to 8.5 s, and device reads were served again within 3.5 s of that in 29 of 32 drills. Wait for a device read, not READY - In a fleet-wide device outage the `snap` channel reported the failure within about 30 ms, but the other metrics channels kept returning each device's last values for about 4.7 s, until the Avalon driver's 5 s cache expired - One Gateway connects to exactly one Kernel. Multi-site views are built by your own application calling each site's Gateway - Every number here comes from one developer workstation with simulated devices on loopback. Treat them as evidence about how MDK behaves, not as capacity guarantees for your hardware ## Overview This page publishes representative results from the [benchmark harness](/guides/deployment/benchmark-your-site#overview), which boots real Kernel, Gateway, Worker, and mock-device processes and measures them under load, through Worker restarts, Kernel restarts, and a fleet-wide device outage. It also sets out the operating procedures and limits those runs support, including the current one-Kernel-per-Gateway constraint and how multi-site aggregation works because of it. The page separates four kinds of statement, and every number below is one of them: | Kind | Meaning | | ------------------------------ | ----------------------------------------------------------------------------------------------- | | Measured result | A value the harness recorded in these runs, on this hardware, with these settings | | Measured limit | A point where a measured profile crossed a pass/fail threshold or a run stopped working | | Recommended operating envelope | A deliberately conservative range derived from the measured results, with the reasoning stated | | Architectural constraint | A limit set by how MDK is built today, true on any hardware | A measured limit says where this setup stopped passing. It does not say where yours will: real devices answer more slowly than a loopback mock, and a different host, device family, or operating interval moves every limit. ## Test setup ### Hardware and software | Field | Value | | -------------- | ------------------------------------------------------------------------ | | Host | Apple M3 Max, 14 cores, 36 GiB RAM, NVMe SSD (developer workstation) | | Reference tier | `custom`: not an `edge` or `site-server` reference host | | OS | macOS 15.8.1, darwin arm64 | | Node.js | v24.19.0 | | MDK | 0.12.1 at commit `2d71e224`, with no uncommitted harness changes | | Devices | Simulated Canaan Avalon A1346 miners, `@tetherto/mdk-worker-avalon` mock | | Network path | Loopback, one mock TCP listener per Worker | | Date | 2026-10-27 | | Config hash | `2f48ab70dc13` (`config/benchmark.config.json`) | ### Topology Every profile runs the same process layout a supervised deployment uses: the mocks, the Kernel, the Gateway (with the harness's `fleet-summary` app runtime in the same process), and each Worker run as separate OS processes on one host. The Kernel discovers Workers through the shared-directory (`local`) mode. All devices behind one Worker share that Worker's mock listener. ### Load and operating parameters | Parameter | Value | | ------------------------------- | ------------------------------------------------------------------------------------------- | | Soak before measuring | 60 s | | Kernel telemetry pull interval | 10 s | | Worker snap collection interval | 10 s (the production default is 60 s; the harness shortens it to get cycles in a short run) | | Harness read sweep concurrency | 500 devices in flight per Worker | | Per-device request timeout | 10 s (`thing.miner.timeout`) | | Sequential latency samples | 50 per row | | Read saturation load | 1,000 concurrent readers for 3 s | | Action load | 1,000 `setPowerMode` submits per second, open loop, for 3 s | | Alert induction | Real Avalon temperature alert with its threshold forced below any reading | | Gateway rate limit | Off for the harness (see [Gateway rate limit](#gateway-rate-limit)) | ### Profiles Profiles are named by total devices and Worker count. Tables show the median where a profile ran more than once, with the range where it matters. | Profile | Devices | Workers | Runs | | -------------------------- | ------- | ------- | ---- | | `cap-10devices-1workers` | 10 | 1 | 3 | | `cap-100devices-1workers` | 100 | 1 | 3 | | `cap-500devices-1workers` | 500 | 1 | 1 | | `cap-1000devices-1workers` | 1,000 | 1 | 3 | | `cap-1000devices-2workers` | 1,000 | 2 | 1 | | `cap-2000devices-1workers` | 2,000 | 1 | 1 | | `cap-2000devices-4workers` | 2,000 | 4 | 1 | | `cap-5000devices-1workers` | 5,000 | 1 | 1 | | `cap-5000devices-5workers` | 5,000 | 5 | 2 | Before measuring, every profile checks that every device answers a metrics pull and stops if one does not. Every timed call counts an error payload, a `FAILED` command, or a failed `snap` read as an error, never as a fast success. ## Results ### Capacity and throughput Cycle time is one full read sweep of the busiest Worker's devices. Headroom is that cycle time divided by the 10 s interval: below 0.7 is green, 0.7 to 1.0 amber, 1.0 or more red. | Devices / Workers | Verdict | Cycle (ms) | Headroom | Reads/s at saturation | Actions accepted/s | Busiest process, avg CPU (% of one core) | | ----------------- | ----------------------- | ---------- | -------- | --------------------- | ------------------ | ---------------------------------------- | | 10 / 1 | green, amber in 1 of 3 | 444 | 0.044 | 33 | 889 | Worker, 4.1 | | 100 / 1 | green | 338 | 0.034 | 190 | 889 | Worker, 8.9 | | 500 / 1 | green | 382 | 0.038 | 749 | 894 | Worker, 23.2 | | 1,000 / 1 | green | 758 | 0.076 | 926 | 884 | Worker, 39.8 | | 1,000 / 2 | green | 438 | 0.044 | 1,479 | 886 | Kernel, 22.5 | | 2,000 / 1 | green | 2,003 | 0.200 | 994 | 828 | Worker, 65.3 | | 2,000 / 4 | green | 624 | 0.062 | 1,766 | 863 | Kernel, 32.4 | | 5,000 / 1 | **red** | 12,403 | 1.240 | 737 | 902 | Worker, 108.3 | | 5,000 / 5 | green in 1 of 2, **red** in 1 of 2 | 3,496 | 0.350 | 490 and 1,466 | 88 and 732 | Kernel, 28.7 | The 10-device amber run timed out 14 of its saturation reads (`ERR_MDK_REQUEST_TIMEOUT`). That is the load shape, not a fault: 1,000 readers against 10 devices queue about 100 reads per device, and reads to one device run one after another (see [Read latency is driver-bound](#read-latency-is-driver-bound)). The red 5,000 / 5 run kept its reads and collection green and failed both command thresholds. 2,105 of its 2,524 action submits were rejected with `ERR_WORKER_NOT_ROUTABLE`, and the 419 it accepted took 2,591 ms at p99, against the 2,000 ms action threshold. The Kernel returns that code when the target Worker is not `READY` or its [health monitor](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#healthmonitor) marks it `SICK` or `DEAD`. The monitor pings each Worker every 5 s with a 3 s timeout, and a single missed ping marks the Worker `SICK` until a later ping succeeds. Reads do not pass through that check. The run did not log Worker health states, so the missed pings are inferred from the code path rather than observed. One green and one red run is not a stable result, so the page treats 5,000 devices on one Kernel as beyond the tested range. Saturation throughput grows with device count until one Worker's CPU limits it. Splitting the same devices across more Workers raises it: 1,000 devices served 926 reads/s on one Worker and 1,479 on two, and 2,000 devices served 994 on one Worker and 1,766 on four. ### Latency All values in milliseconds. Device read, fleet summary, and single action accept go Client → Gateway → app runtime → Kernel → Worker → device and back, one request at a time. Action accept under load is the harness calling the Kernel directly at 1,000 submits per second for 3 s. | Devices / Workers | Device read p50 | p95 | p99 | Fleet summary p50 | p95 | Action accept, single p50 | p99 | Action accept under load p50 | p99 | | ----------------- | --------------- | ----- | ----- | ----------------- | ------ | ------------------------- | ---- | ---------------------------- | ---------------- | | 10 / 1 | 222 | 330 | 530 | 325 | 494 | 3.8 | 14.7 | 1.0 | 20.2 | | 100 / 1 | 319 | 404 | 564 | 250 | 441 | 3.2 | 14.6 | 1.1 | 22.7 | | 500 / 1 | 244 | 394 | 671 | 332 | 716 | 3.3 | 17.8 | 4.4 | 57.9 | | 1,000 / 1 | 273 | 395 | 754 | 612 | 1,001 | 3.4 | 16.9 | 29.9 | 105 | | 1,000 / 2 | 270 | 363 | 409 | 469 | 722 | 3.0 | 23.6 | 30.6 | 95.8 | | 2,000 / 1 | 325 | 528 | 1,113 | 1,291 | 2,407 | 3.6 | 12.8 | 17.0 | 282 | | 2,000 / 4 | 246 | 439 | 1,610 | 959 | 2,573 | 3.4 | 59.9 | 58.7 | 147 | | 5,000 / 1 | 391 | 4,190 | 5,683 | 10,069 | 10,487 | 3.0 | 8.8 | 85.9 | 664 | | 5,000 / 5 | 339 | 3,643 | 5,284 | 7,040 | 10,998 | 3.7 | 67.2 | 423 and 951 | 703 and 2,591 | "Action accept" is the time for the Kernel to accept a command (`status: 'QUEUED'`). Nothing has reached the device at that point; the harness does not time execution or confirm the outcome (see [What these results don't cover](#what-these-results-dont-cover)). A single command is accepted in a few milliseconds at every fleet size. Under sustained submits, acceptance slowed as the fleet grew: from 1,000 devices up it took tens of milliseconds at p50, and at 5,000 devices across 5 Workers it took 423 ms at p50 in the green run and 951 ms in the red one, whose p99 of 2,591 ms crossed the 2,000 ms action threshold. The 5,000 / 5 row gives both runs, green first, because one run failed and a median would hide that. The fleet summary is the harness app's aggregate route: it reads every device through the Kernel, so its latency tracks fleet size. Every column except action accept under load rests on 50 samples per run, so treat p95 as the representative tail there; the loaded column rests on about 2,500 to 2,700 accepted submits per run, 419 in the red 5,000 / 5 run. #### Read latency is driver-bound A single device read costs about 220 to 390 ms at p50 in every profile, and the Avalon driver appears to account for nearly all of it. In an informal check outside the harness, the driver timed on its own against the same mock, with no Kernel, Worker runtime, or HRPC in the path, took 318 to 333 ms for one full metrics pull. That check is not part of the harness and its output is not among the published reports, so treat the split as an observation, not a measured result. Most of that is the `snap` channel, which reads the device live on every call. The other channels read through the driver's 5 s device-data cache (`fetchDeviceData`), so they only reach the device when their cached data is older than 5 s. Two consequences follow: - MDK's own hops add little to a single read; the device protocol sets the latency, and a real miner on a LAN will be slower than this loopback mock - Reads to one device are serialized, so one device sustains about 3 reads/s. Saturation throughput scales with the number of devices, not the number of concurrent callers ### Resource use Peak resident memory (RSS) per process, and the busiest Worker's values where there is more than one. CPU is the average over the run as a percentage of one core. | Devices / Workers | Kernel CPU | Kernel RSS (MiB) | Worker CPU | Worker RSS (MiB) | Gateway CPU | Gateway RSS (MiB) | All MDK processes, RSS (MiB) | | ----------------- | ---------- | ---------------- | ---------- | ---------------- | ----------- | ----------------- | ---------------------------- | | 10 / 1 | 3.7 | 399 | 4.1 | 233 | 0.8 | 149 | 779 | | 100 / 1 | 6.6 | 403 | 8.9 | 338 | 1.2 | 152 | 905 | | 500 / 1 | 14.0 | 436 | 23.2 | 584 | 2.3 | 208 | 1,227 | | 1,000 / 1 | 20.6 | 442 | 39.8 | 786 | 3.6 | 262 | 1,494 | | 1,000 / 2 | 22.5 | 442 | 21.9 | 567 | 3.7 | 282 | 1,852 | | 2,000 / 1 | 28.5 | 543 | 65.3 | 1,120 | 4.9 | 293 | 1,957 | | 2,000 / 4 | 32.4 | 440 | 20.9 | 546 | 5.8 | 316 | 2,923 | | 5,000 / 1 | 26.2 | 696 | 108.3 | 1,888 | 4.0 | 354 | 2,939 | | 5,000 / 5 | 28.7 | 481 | 26.5 | 785 | 4.8 | 339 | 4,558 | A Kernel, a Gateway, and one Worker with no devices together peaked at about 340 to 400 MiB. Each extra Worker process adds about 80 to 120 MiB before it owns any device, which is why splitting devices across more Workers raises total memory while lowering each Worker's CPU. The Kernel's own CPU tracks total device count more than Worker count. Each run soaked for 60 s, not 24 h, so memory and file-descriptor slopes in the per-profile reports are indicative and make no growth or leak claim. On-disk growth is not characterized for the same reason. ### Failure drills Each drill runs after the measurements finish, against the same on-disk stores, so identities survive the restart. Every time counts from the moment the process is killed. | Scenario | Metric | Up to 2,000 devices total | 5,000 on one Worker | 5,000 across 5 Workers | | ----------------- | ---------------------------------------- | --------------------------- | ------------------- | ---------------------- | | Worker restart | Kill to READY with all devices owned | 3.4–5.5 s | 5.6 s | 4.6–5.5 s | | Worker restart | Kill to device reads served again | 4.9–9.7 s (one run 20.8 s) | 9.7 s | 6.1–7.9 s | | Kernel restart | Kill to READY (answers `getStatus`) | 4.4–8.5 s | 4.6 s | 4.1–6.4 s | | Kernel restart | Kill to device reads served again | 5.2–8.9 s | 6.7 s | 5.5–6.7 s | | Fleet-wide outage | Kill to first failed read | 12–32 ms | 24 ms | 25–28 ms | | Fleet-wide outage | Kill to every metrics channel failing | 4.6–4.7 s | 4.7 s | 4.6–4.7 s | | Fleet-wide outage | Mocks restarted to reads served again | 317–333 ms | 343 ms | 324–334 ms | | Unresponsive devices | Worst-case sweep time (analytical) | 10 s × ⌈devices / 500⌉ | 100 s | 100 s | READY and device reads being served again are separate events for both restarts: the restarted process answers status calls before its devices are reachable through the Kernel again. The gap was under 3.5 s in 29 of 32 drills. One 10-device Worker restart took 20.8 s to serve reads after reaching READY in 3.4 s, and the harness recorded nothing that explains it. The outage drill stops the process serving every device's mock at once, so it can only make the whole fleet unreachable, never one device. A stopped listener refuses connections immediately, and every failed read in the drill returned in under 10 ms. The first failed read came from the `snap` channel, which reads the device live and reports the failure as `{ success: false }`. The other channels kept returning each device's last values, including `status: "mining"`, until the driver's 5 s device-data cache expired, after which every channel returned `ECONNREFUSED`. A device that hangs instead of refusing is bounded by the 10 s per-device timeout; the analytical row multiplies that by the number of 500-device concurrency waves, which is a worst case, not a measurement. ## Measured limits | Limit | Evidence | | --------------------------- | --------------------------------------------------------------------------------------------------- | | Devices per Worker | Green at 2,000 (headroom 0.200, Worker CPU 65%). Red at 5,000 (headroom 1.240, Worker CPU 108%) | | Concurrent reads per device | 1,000 readers on 10 devices timed out 14 reads in one of three runs; reads to one device are serialized | | Devices per Kernel | Green at 2,000 across 4 Workers. At 5,000 across 5 Workers, one green run and one red run | | Commands under host load | In the red 5,000 / 5 run, 2,105 of 2,524 action submits rejected with `ERR_WORKER_NOT_ROUTABLE`, and the 419 accepted took 2,591 ms at p99 against a 2,000 ms threshold | The Kernel never averaged more than about 32% of one core in any profile, so the harness found no Kernel CPU limit. The red 5,000 / 5 run points at the Kernel's Worker health check rather than its CPU: the rejection code comes from that check, and reads, which skip it, stayed green. ## Architectural constraints ### One Kernel per Gateway A Gateway connects to exactly one Kernel. `startGateway()` resolves a single `kernelKey` and hands that one key to every app runtime it hosts, and `mdk.yaml` declares one Kernel per stack. No MDK component spans sites, federates Kernel registries, or shares state between Kernels. Add Kernels for separate sites, not to work around one site's device count: inside a site, scale by adding Workers, which the [scalability model](/concepts/scalability) covers. ### Multi-site aggregation lives in your application A view across sites is built at the application layer: an aggregator or service of your own calls each site's Gateway over HTTP and merges the responses. ```mermaid flowchart TD Agg["Your aggregator service"] subgraph site_a ["Site A"] GW_A["Gateway"] -->|HRPC| K_A["Kernel"] K_A --> W_A["Workers"] end subgraph site_b ["Site B"] GW_B["Gateway"] -->|HRPC| K_B["Kernel"] K_B --> W_B["Workers"] end Agg -->|HTTP| GW_A Agg -->|HTTP| GW_B style site_a fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A style site_b fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A ``` The benchmark measured one site. For an aggregator, that means: - Each site keeps its own capacity: size every site against the tables, independently - A cross-site request is as slow as the slowest site it waits for. A fleet summary at 2,000 devices took about 1.3 s at p50 on one site, so fan out to sites in parallel and set a per-site timeout - A failure at one site leaves the others untouched, so the aggregator should return partial results with a per-site error rather than failing the whole request - Each Gateway enforces its own rate limit on the aggregator (see [Gateway rate limit](#gateway-rate-limit)) ### Gateway rate limit The Gateway's default rate limit is 300 requests per minute per client IP. An aggregator, a dashboard backend, or any other single client calling one Gateway hits it at 5 requests per second on average. The harness turns the limit off, because it is itself a single client driving deliberate load. While the harness was being developed, runs of 500 devices or more with the default on were observed to have every Gateway request after the first few hundred rejected with `429`; those runs are not published, so treat this as an observation, not a measured result. Budget your aggregator's polling against the limit, or raise `rateLimit` on the Gateway for that client's address. ## Recommended operating envelopes These are starting points derived from the measurements, set below the measured limits on purpose. Validate them on your own hardware with [the benchmark harness](/guides/deployment/benchmark-your-site#overview) before relying on them. | Envelope | Recommendation | Reasoning | | ------------------------------------ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | Devices per Worker | Up to 1,000 | Half the largest green profile. Worker CPU 40%, headroom 0.076, restart to READY about 4 s | | Collection headroom on your hardware | Below 0.7, re-measured whenever the device count or interval changes | The harness threshold for green; real devices answer more slowly than the loopback mock | | Worker CPU | Keep the busiest Worker below 80% of one core | The one profile above that line also failed collection headroom | | Restart recovery | Expect device reads to fail for up to about 10 s after a Worker or Kernel restart | The slowest time to serve reads again was 9.7 s, apart from one 20.8 s Worker restart | | Command submits | Retry `ERR_WORKER_NOT_ROUTABLE` with backoff | One missed 3 s health ping makes the Kernel reject every command to that Worker until a later ping succeeds | | Callers per device | Do not plan on more than about 3 reads/s per Avalon device | One metrics pull costs about 330 ms in the driver, and reads to one device run one at a time | | Devices per Kernel | Up to 2,000 until you have measured more on your own hardware | The largest total that was green every time it ran. 5,000 was green once and red once | | Kernels | One per site | Architectural: one Kernel per Gateway, no cross-Kernel state | ## Operating procedures ### Restart a Worker 1. Restart the Worker process under its supervisor; its identity comes from its store directory, so it re-registers with the same key. 2. Wait until Kernel status shows the Worker `READY` with its full device count. Expect about 4 to 6 s. 3. Do not treat `READY` as recovered. Poll a real device read on that Worker until it succeeds, which took 4.9 to 9.7 s from the kill in these runs, and 20.8 s once. 4. Reads and commands for that Worker's devices fail until then; other Workers' devices are unaffected. ### Restart the Kernel 1. Restart the Kernel process. It recovers from its own write-ahead log: [commands in flight settle as `TIMEOUT`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#commandstatemachine) with `ERR_COMMAND_UNCONFIRMED` and are never re-sent, so reconcile them by reading the device state back. 2. Do not treat `READY` as recovered. Poll a real device read until it succeeds, which took 5.2 to 8.9 s from the kill in these runs. 3. Workers re-register with the restarted Kernel on their own, and the harness's existing Kernel client reconnected without being recreated. Restarting Gateways after a Kernel restart was not exercised. ### Respond to a site-wide device outage 1. Read `snap.success` in a metrics response to tell whether the device answered this read. It turned `false` within about 30 ms of the outage. 2. Do not rely on the other metrics channels or the [device health channel](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/README.md#device-health-channel) for the first few seconds: for about 5 s the cached channels keep returning the last values. The health channel marks a device unreachable only when no channel answers, so it keeps reporting `reachable` until the cache expires; the drill did not record it, this follows from the Worker runtime's code. After that every channel fails and the health channel records the error. 3. Expect a hung device to cost up to the 10 s per-device timeout per concurrency wave in each collection sweep; a refused connection costs almost nothing. 4. After devices return, reads succeed again within about 350 ms; no MDK process needs restarting. ### Scale a site 1. Add Workers before a Worker approaches 1,000 devices, or earlier if its CPU or headroom says so. 2. Keep one Kernel and one Gateway per site; a second Kernel means a second site. 3. Re-run the benchmark after the change, on the production host, with your device family. ## What these results don't cover - Real hardware, a real network, or any device family other than the Avalon A1346 mock - Hung devices: the outage drill only measures refused connections; the hung case is analytical - Command execution: action rows time Kernel acceptance only, and the approval workflow is not exercised - Worker health states over time: the harness does not record when the Kernel marks a Worker `SICK` - More than one host, DHT discovery, or the MCP server - Growth and leaks: every run soaked for 60 s, not the 24 h the [capacity template](/guides/deployment/capacity-metrics-template) requires for those claims - The Gateway rate limiter under load, which the harness turns off - Statistical confidence: most profiles ran once or twice, and the p99 of every one-at-a-time latency rests on 50 samples ## Reproduce these results The per-profile reports, in the [capacity template](/guides/deployment/capacity-metrics-template) format, are in [`backend/tests/benchmark/published/2026-10-27-m3max/`](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/published/2026-10-27-m3max/README.md). They come from harness commit `2d71e224`; numbers from a harness before that commit are not comparable, because earlier versions measured a fleet whose devices were all offline. To produce your own: ```bash cd backend/tests/benchmark ./setup-config.sh # copies config/benchmark.config.json.example; fill in its hardware block node processes/run-process.js --id cap-1000devices-2workers --devices 1000 --workers 2 --sweep representative ``` Run the host with sleep disabled for the whole run (for example `caffeinate -i` on macOS): a suspended host stalls every process and invalidates the profile. ## Next steps - [Benchmark your site](/guides/deployment/benchmark-your-site): run the same harness on your own hardware and device count - [Capacity and metrics template](/guides/deployment/capacity-metrics-template): what every profile records and the pass/fail thresholds - [Scalability](/concepts/scalability): devices per Worker, Workers per Kernel, and Kernels per site - Deployment topologies: how these processes are packaged on one or many hosts # 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). ## Next steps - [Benchmark results and operating limits](/guides/deployment/benchmark-results): representative results from this harness, measured limits, and recommended operating envelopes 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 same suite with coverage — CI enforces >= 80% (lines, statements, # functions, branches) on this package npm run test:coverage # 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 - Gateway rate limit (off for the harness) and the Gateway alert-poll sample size - 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. The harness Gateway runs with its rate limit off (`GATEWAY_DEFAULTS.rateLimit`). The Gateway's default, 300 requests per minute per client IP, limits one client, and the harness is a single loopback client driving deliberate load, so with the limiter on it would measure the limiter rather than the Gateway. A production Gateway keeps its default. ### Device reachability and error payloads A Worker answers a request for a device it holds offline (one whose `connect()` failed at boot) with a normal response carrying `ERR_DEVICE_UNAVAILABLE`, not a rejected call. Two checks keep that from passing as a fast, successful round trip: - Before measuring anything, every profile pulls metrics from every device and stops with `ERR_BENCH_DEVICES_UNREACHABLE` if any device fails - Every timed call goes through `assertTelemetry` or `assertCommand` in [`lib/load.js`](https://github.com/tetherto/mdk/blob/main/backend/tests/benchmark/lib/load.js), so an error payload, a `FAILED` command result, a failed `snap` channel, or a metrics response where every channel failed counts as an error `snap` is checked on its own because it is the only channel that always reads the device live, and it reports a failed read as `{ success: false }` rather than `{ error }`. The other channels read through the miner's 5 s device-data cache (`fetchDeviceData` in `backend/core/mdk/lib/things/miner.js`), so for up to 5 s after a device stops answering they still return its last values. ### 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`). The Gateway side polls an evenly spread sample of up to `GATEWAY_DEFAULTS.alertSampleSize` devices, not the whole fleet. `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. Both restart drills report two times from the kill: until the restarted process is READY and until a real device read succeeds again, since READY does not mean reads are being served yet. The outage drill can only make the **whole fleet** unreachable, not one device — every device behind a Worker shares one mock server. After killing the mocks it reads one device until a read fails and until every channel fails (the cached channels keep serving the last values until the cache expires), then restarts the mocks and times how long reads take to succeed again. It also reports an analytical hung-device worst case (`timeout × ⌈device count / concurrency⌉`) from already-known operating parameters, since a closed port is refused immediately rather than timing out. 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 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; RBAC: per-root service accounts (bench-harness, mdk-gateway, app:fleet) bound to one full-access role, applied by bootKernel 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) tests/*.test.js unit tests for lib/, the checklist and the sweep (fake client + Gateway, no real stack) 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) Every control on this page is a network setting, an admission list, or a line of policy. MDK identifies the person or agent behind a request from the access token the Kernel's token issuer signed, and `spec.rbac` decides what that identity may do. TLS, network isolation and secret storage are still yours. ## 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, which lets them connect and nothing more ([Kernel admission](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#caller-admission-and-authorization)) - Check an access token and one `spec.rbac` rule on every Kernel request and every Gateway app route, default deny ([policy model](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md)) - Hold an app to the `app-` role `spec.rbac` binds to its service account `app:`, seeded at install from the app's `mdk-app.yaml` `permissions`, and check the signed-in user's own rules as well when the app acts for one ([app runtime](https://github.com/tetherto/mdk/blob/main/backend/core/app-runtime/README.md)) - Record every `command.request` attempt, refused ones included, with its caller, target, params and outcome ([write-command log](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#write-command-log)) - 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 each agent's own access 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 (the [local and air-gapped profile](/guides/deployment/private-dht), from code, `worker.js` or `spec.network.dht` in `mdk.yaml`) MDK cannot: - Federate sign-in. Users, groups and passwords live in the Kernel's identity store, and there is no SSO hook. - 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 roles and bindings in Profile A, not one flag. - Review the grant an app asks for. `mdk validate app ` checks that the grant matches the request and binds somebody; whether that grant is narrow enough is your call before adding the app to `spec.apps`. ## Before either profile Both profiles are policy, so the policy exists before the stack boots. A `spec.rbac` that binds no user and no group, and whose bound subjects include nobody who may write the policy, is refused at `mdk run` rather than booted: a Kernel in that state cannot be handed a policy afterwards. That is a fresh stack, or one where only an app's account is bound. Run [`mdk rbac init`](https://github.com/tetherto/mdk/blob/main/packages/cli/README.md#access-policy) and `mdk user add --admin` first, then pick a profile. Binding a user is enough to boot, so a policy that keeps `policy: write` out of every role still starts. That is a choice rather than an error: policy changes then go through `mdk.yaml` and a restart instead of `mdk rbac apply`. Admission is the same in both: the Kernel admits the Gateway, the MCP server and each app runtime key so they can connect, and every Worker admits the Kernel client key (`kernel.getClientPublicKey()`) and nothing else, so every device call passes the Kernel's checks and [write-command log](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#write-command-log). What separates the two profiles is `spec.rbac`. ## Profile A: monitoring-only The site reads telemetry and state. While this policy is in force, no route, tool or Kernel caller can send a command to a device. - Roles grant `devices: read`, `workers: list` and the read verbs of the app routes operators use; none carries `devices: write`, `actions: write` or `actions: vote` - Replace every wildcard administrator grant, the CLI account's included, with a dedicated policy-maintenance role holding `policy: read`, `policy: write` and the read permissions its callers need, bound only to whoever maintains `mdk.yaml`. Do not bind the `admin` role in this profile: it holds every resource and verb, `devices: write` and `actions: write` among them, so binding it breaks the guarantee above. `mdk rbac init` binds `mdk-cli` to `admin` by default, so rebind that account to the narrower role before relying on this profile. - Each app's `app-` role in `spec.rbac` carries no write verb, so the app cannot reach `command.request` even when a user asks it to. The role is seeded from the app's `mdk-app.yaml` `permissions` at install and is yours from then on, so audit the role rather than the manifest. - `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 nobody holding `actions: write`, `action.push` is refused An identity whose rules cover no write verb gets `ERR_KERNEL_ACTION_FORBIDDEN` on `command.request` or `action.push`. A refused `command.request` is recorded in the [write-command log](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#write-command-log); a refused `action.push` is denied before dispatch and leaves no entry. Monitoring-only rests on the policy, so read it back with `mdk rbac show` after every change. An identity with `policy: write` can replace this policy with one that permits device writes; this profile does not prevent its maintainers from enabling writes through a policy change. ## Profile B: delegated writes Named operators may change the fleet, and every fleet-changing write waits for approval. - Roles add `devices: write` on the devices each team owns, scoped with `resourceNames` such as `miner/antminer/*` or `miner/antminer/rack-3/*`, and `actions: write` and `actions: vote` on the people who push and approve. A device name is `family/brand/worker/device` and a pattern is anchored at both ends, so a pattern that does not start at the family segment matches nothing and denies. - Bindings name `Group` subjects rather than individuals, so joining a team is an identity change and not a policy change - Users sign in at the Gateway's `POST /auth/login`; the Gateway checks a bearer token and the route's rule before body validation, the cache or the app, and the app receives the verified identity as `req._info`, never the token ([auth in app controllers](/guides/gateway/apps#auth-and-permissions)) - An app acting for a signed-in user calls Kernel through `req.mdk`, so Kernel requires both the app's and the user's rules to allow the write; a payload's self-asserted `authPerms` are ignored for any caller that carries a token - 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` - MCP write tools stay hidden unless `spec.mcp.allowWrite` or `spec.mcp.allowTools` names them, and each agent signs in with its own service account whose rules cover the tools it may call ## 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 - Every `/mcp` call carries one agent's own access token: `mdk sa add ` creates the credential, and the agent signs in at the Gateway's `POST /auth/token`. `spec.mcp.token` and `spec.mcp.identity` are refused in `mdk.yaml` - The agent's rules decide which app routes are listed as tools at all, and a route that declares a `resourceName` is checked again on each call - 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 signs in as its own service account like any other agent, 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 pinned Worker admission or the Gateway bind address as stack settings. It admits callers from the trusted-keys directory, which lets them connect and nothing more, so both profiles are reached from `spec.rbac` rather than from the admission list. A host that names every caller key itself disables automatic directory admission with `trustedKeysDir: false`. 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 | | -------------------------------- | ------------------------------------------- | --------------------------------------------------------- | --------------------------------- | ------------------------------------------ | | Access policy | `spec.rbac`, written by `mdk rbac init`, `mdk user add` and `mdk sa add`, applied on `mdk rbac apply` | `getKernel({ rbac })` | Read verbs, plus `policy: write` for policy maintenance; no wildcard grants | Adds `devices: write`, `actions: write` and `actions: vote` on named roles | | Kernel caller allowlist | Not exposed; trusted-directory callers are admitted | `getKernel({ hrpc: { whitelist }, trustedKeysDir: false })`; explicitly manage later admissions | Every backend caller key, and no roles | Same | | 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` | The Kernel client key alone | Same | | Device address allowlist | Not exposed | `conf.thing.allowedAddressCidrs` on Worker boot functions | The device VLAN CIDR | Same | | DHT bootstrap list and bind interface | `spec.network.dht` (`host`, `port`, `bootstrap`) | `getKernel({ hrpc: { bootstrap, dhtHost } })`, `bootstrap` and `dhtHost` on Worker boot functions and `startGateway()`; `MDK_DHT_BOOTSTRAP` and `MDK_DHT_HOST` for `worker.js` | The [private profile](/guides/deployment/private-dht) 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 identity | Not exposed; `spec.gateway.config.authVerifier` is refused | `startGateway({ kernel })` in process, or `startGateway({ gatewayCredential })` against a Kernel elsewhere | The Kernel's token issuer | Same | | MCP | `spec.mcp` absent, or `port`, `allowWrite`, `allowTools` | `createMcpServer(root, port, config, pluginDirs, apps, options)` | Absent, or defaults | `allowWrite` only for agents whose rules cover the tools | | 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 - Compare [HRPC with a known-endpoint RPC](/concepts/transport-comparison): setup, failure modes, latency and diagnosability at a managed site - Read the signed-in user in [app controllers](/guides/gateway/apps#auth-and-permissions): what `req._info` and `req.mdk` carry, and how a route's rule is chosen - 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 MDK on a private HyperDHT (/guides/deployment/private-dht) ## Overview This page sets up the local and air-gapped profile: every MDK process joins a HyperDHT network you run, binds its DHT node to an interface you choose, and never contacts the public `node1/2/3.hyperdht.org` bootstrap nodes. It follows hyperdht's own [isolated-DHT pattern](https://github.com/holepunchto/hyperdht#isolated-dht-network): one bootstrap node plus persistent DHT nodes, and every component started with that bootstrap list. The profile is HyperDHT configuration only. There is no proxy, relay or second transport in front of HRPC. If Kernel, Worker, Gateway or HRPC are unfamiliar, read [terminology](/reference/glossary) first. ## Discovery and transport are separate Discovery is how the Kernel learns a Worker's public key. Transport is how it then reaches that key. Every discovery mode, including the same-process case, uses HRPC over HyperDHT, so every mode needs a reachable bootstrap. Moving to a private network changes the bootstrap, not the discovery mode. | Shape | How the Kernel learns the Worker key | Transport | What the private profile changes | | -------------- | -------------------------------------------------------- | ------------------ | --------------------------------------------------------- | | Same process | `kernel.registerWorker(rpcKey)` in memory | HRPC over HyperDHT | Both DHT nodes join the private bootstrap and bind `dhtHost` | | Local | Key files in `/.worker-keys` on one machine | HRPC over HyperDHT | Same | | Microservices | A HyperDHT topic the Kernel and every Worker join | HRPC over HyperDHT | Same; the topic announce and lookup also stay on the private network | A same-process Kernel and Worker still open a HyperDHT connection to each other. Without a reachable bootstrap, public or private, that connection never forms and the Worker never reaches `READY`. The [deployment topology concept](https://github.com/tetherto/mdk/blob/main/backend/core/docs/README.md#connection-and-deployment-model) covers the three shapes in depth. ## Choose a bind interface `dhtHost` is the interface each DHT node binds (hyperdht's `host` option). Without it a node binds every interface. | Deployment | Bootstrap reached at | `dhtHost` on every component | | -------------------------------- | -------------------------- | ----------------------------------- | | One machine | `127.0.0.1:` | `127.0.0.1` | | An isolated LAN | `:` | That machine's address on the LAN | | Docker Compose (`startServices`) | `.2:` | The container's fixed address, set for you | Every process in the stack needs the same bootstrap list. The Kernel, Workers, Gateway, app runtimes, the MCP server and CLI clients each start a DHT node, and one process left on the defaults joins the public network. ## Pass the profile to each component | Component | Programmatic option | Service environment (`worker.js`) | | ------------------------- | ----------------------------------------------------------------------- | ---------------------------------- | | Bootstrap | `startDhtBootstrap({ host, port, nodes })` | `SERVICE=dht-bootstrap`, `MDK_DHT_HOST`, `MDK_DHT_PORT`, `MDK_DHT_NODES` | | Kernel | `getKernel({ hrpc: { bootstrap, dhtHost } })` | Not run by `worker.js` | | Worker | `bootstrap`, `dhtHost` on every Worker boot function and `WorkerRuntime` | `MDK_DHT_BOOTSTRAP`, `MDK_DHT_HOST` | | Gateway | `startGateway({ bootstrap, dhtHost })` | `MDK_DHT_BOOTSTRAP`, `MDK_DHT_HOST` | | App runtime | `createAppRuntime({ bootstrap, dhtHost, kernelBootstrap, kernelDhtHost })` | Set by `mdk run` | | MCP server and plugins | `config.kernelBootstrap`, `config.kernelDhtHost` | Set by `mdk run` | | Client | `createMdkClient({ kernelBootstrap, kernelDhtHost })`, or `hrpc: { bootstrap, dhtHost }` | Not applicable | `startGateway({ bootstrap, dhtHost })` covers all three DHT nodes the Gateway starts: its net facility (the key the Kernel and Workers admit), its Kernel client and its app runtime client. Bootstrap lists that name a public `*.hyperdht.org` node are refused with `ERR_MDK_DHT_BOOTSTRAP_PUBLIC`. ## Run the profile Each subsection is an alternative; pick the one that matches how the stack runs. ### A. Programmatic, one machine Start the bootstrap first and hand its list to every component: ```js const { startDhtBootstrap, getKernel, startGateway } = require('@tetherto/mdk-core') const { startAvalonWorker } = require('@tetherto/mdk-worker-avalon') const storeDir = '/var/lib/mdk/avalon-1' const net = await startDhtBootstrap({ host: '127.0.0.1', port: 49737 }) const dht = { bootstrap: net.bootstrap, dhtHost: '127.0.0.1' } const kernel = await getKernel({ hrpc: dht, discovery: { mode: 'local' } }) const worker = await startAvalonWorker({ workerId: 'avalon-1', model: 'a1346', storeDir, allowedKeys: [kernel.getClientPublicKey()], ...dht }) await kernel.registerWorker(worker.runtime.getPublicKey()) const gateway = await startGateway({ kernel, ...dht }) ``` Close the bootstrap with `net.close()` after every component has stopped. ### B. `mdk run` Add `spec.network.dht` to `mdk.yaml`: ```yaml spec: network: dht: host: 127.0.0.1 # bind interface, and where the generated bootstrap is reached (default 127.0.0.1) port: 49737 # UDP port of the generated bootstrap ``` The process that runs the Kernel (`mdk run`, or `mdk run kernel` in a split stack) also runs the bootstrap and stops it after the Kernel. Every other target (`gateway`, `mcp`, `worker `, `app `) and every CLI command that calls the Kernel joins `host:port`. To join a bootstrap you already run elsewhere, list it and no bootstrap is started. Set `host` to this machine's LAN address, or leave it unset to bind every interface. A loopback `host` with a non-loopback bootstrap is refused, because a socket bound to loopback cannot reach it: ```yaml spec: network: dht: host: 10.20.0.7 bootstrap: - 10.20.0.5:49737 ``` ### C. PM2 with `startServices` ```js await startServices({ runtime: 'pm2', repoRoot, dht: {}, services }) ``` The generated `ecosystem.config.js` starts `mdk-dht-bootstrap` first, on `127.0.0.1:49737`, and sets `MDK_DHT_BOOTSTRAP` and `MDK_DHT_HOST=127.0.0.1` on every service. For an isolated LAN, pass `dht: { host: '', bootstrap: [':49737'] }` and no bootstrap app is generated. Without `host`, `MDK_DHT_HOST` is not set and every service binds all interfaces. ### D. Docker Compose with `startServices` ```js await startServices({ runtime: 'docker', repoRoot, image, dht: { internal: true }, services }) ``` A container cannot reach another container's `127.0.0.1`, so the Docker profile uses a dedicated bridge network instead of loopback: - The network is `mdk-dht`, subnet `172.31.249.0/24` unless `dht.subnet` says otherwise - The generated `dht-bootstrap` service sits at `.2`, and services take `.10` onward in the order they are listed - Every service gets `MDK_DHT_BOOTSTRAP=.2:49737` and `MDK_DHT_HOST=`, so its DHT node binds that network only - With `internal: true` the network has no route out; each Gateway also joins the default network so its published port works An internal network also cuts Workers off from devices on the site LAN. Leave `internal` off when Workers poll real hardware; the DHT stays private either way, because no service knows a public bootstrap node. The Kernel is not part of the generated file. Run it on the same Docker network, at an address outside the generated ones, with `getKernel({ hrpc: { bootstrap: ['172.31.249.2:49737'], dhtHost: '' } })`. ## Verify the stack is private 1. List the UDP sockets the MDK processes hold, and check each is bound to the chosen interface rather than `0.0.0.0` or `*`: ```bash lsof -nP -iUDP | grep node ``` 2. Start one process with `DEBUG=mdk:dht-bootstrap` set to log the bootstrap address it serves. 3. On an air-gapped host, block outbound UDP `49737` at the firewall and confirm Workers still reach `READY`. The `private-dht` integration test in [`backend/core/mdk/tests/integration/`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/tests/integration/private-dht.test.js) checks the same things for each discovery mode and for the Gateway: sockets bound to `127.0.0.1`, and routing tables that hold loopback nodes only. ## Next steps - [Deployment topologies](https://github.com/tetherto/mdk/blob/main/backend/core/docs/README.md#connection-and-deployment-model): choose between single-process, local and microservices shapes - [Supervised services](/guides/deployment/run-all-workers-site): run a multi-Worker site under PM2 or Docker - [Hosted-site reference configuration](/guides/deployment/hosted-site): the full settings table for a managed site - [`@tetherto/mdk-core` reference](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md): `startDhtBootstrap`, `startServices` options and the DHT error codes # 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), with a project `mdk.yaml` (`mdk onboard` writes one) ## Apps MDK ships Each app MDK ships lives in its own repository, listed in the [supported apps reference](/reference/supported-apps), and its README documents the routes it 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` | Required (`[]` for none) | 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 enforces `spec.rbac` on every call the app makes. `mdk add app` grants the app's service account (`app:`) exactly these `permissions`, as a role in `mdk.yaml` the operator may narrow, so request every Kernel resource the app reads or writes: a call outside them throws `ERR_KERNEL_ACTION_FORBIDDEN` and reaches HTTP as `403`. `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), and that `spec.rbac` in `mdk.yaml` grants `app:fleet` what the manifest requests. ### 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 agent app uses the flat form. Rather than copy a synthetic example, start from a real manifest and trim it: - [`mdk-app-demo/mdk-plugin.json`](https://github.com/tetherto/mdk-app-demo/blob/main/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 - [`mdk-app-agent/mdk-plugin.json`](https://github.com/tetherto/mdk-app-agent/blob/main/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 } } ``` A write a user asks for goes through `req.mdk`, the request's Kernel client. It acts for the signed-in user, so the Kernel checks their own `spec.rbac` rules as well as the app's, and the write-command log records them via the app: ```js // controllers/command.js — dispatch a command for the signed-in user module.exports = async function command (req) { const deviceId = req.params.deviceId const { mode } = req.body const result = await req.mdk.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-app-agent) 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`. No route is open, so [sign in and send the access token](#call-a-route) to reach `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 `mdkClient`: the app runtime's Kernel client, signed in as the app's service account (`app:`), so every call is checked against `spec.rbac` | | `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-app-demo/blob/main/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. Read the series from each Worker's own persisted tail-log with [`pullTelemetry`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md#client-methods), addressed to the device whose history you want: ```js const history = await mdkClient.pullTelemetry(deviceId, { type: 'logs', key: 'stat-1D', tag: 't-miner', start, end }) ``` A `stat-*` log is shared: the read opens the log named by `key` and `tag`, checks the tag belongs to the device you addressed, and returns the stored series without narrowing it to that device. So a response can include figures from devices the caller cannot address, and a caller scoped to one device still reads the shared total ([what `resourceNames` does and does not bound](/guides/security#step-4-write-the-policy-and-put-identity-in-the-gateway)). A tag the addressed device does not carry is refused with `ERR_LOG_TAG_FORBIDDEN`. Do not treat one device as standing for its whole Worker, and do not assume a Worker with no current devices has no retained history. [`demo/controllers/history.js`](https://github.com/tetherto/mdk-app-demo/blob/main/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` 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. [`mdk-app-agent`](https://github.com/tetherto/mdk-app-agent) 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 Every route of an app is checked against `spec.rbac` at the Gateway: the caller needs a bearer access token (`POST /auth/login` issues one) and a rule granting the route's resource and verb. Without a token the Gateway answers `401 ERR_AUTH_UNAUTHORIZED`, and without a rule `403 ERR_AUTH_FORBIDDEN`, before body validation, the cache and your controller. Who may call the route is the operator's to set in `mdk.yaml`; the manifest only says how the route is governed: | Route field | Status | Description | | -------------- | -------- | ------------------------------------------------------------------------------------------------- | | `verb` | Optional | `get`, `list`, `create`, `update` or `delete`. Default: `GET` `get`, `POST` `create`, `PUT`/`PATCH` `update`, `DELETE` `delete` | | `resourceName` | Optional | `{ "param": "" }`, `{ "query": "" }` or `{ "body": "" }`: where the instance a rule's `resourceNames` matches comes from | The resource is `/`: the app's name in `spec.apps` and the route's path template, so the route `/miners/:id` of `ops-center` is `ops-center/miners/:id`. A rule for it: ```yaml spec: rbac: roles: - name: rack3-tech rules: - resources: [ops-center/miners/command] verbs: [create] resourceNames: ["rack-3-*"] # matched against the route's resourceName field bindings: - role: rack3-tech subjects: [{ kind: User, name: tech@example.com }] ``` The verified identity reaches the controller as `req._info`, `{ kind, sub, email?, groups? }`, so identity never comes from the request body. [The Gateway's security model](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) lists the rest of the defaults. #### Call a route Sign in for an access token, then send it as a bearer token. `mdk user add --admin` creates a first operator with a rule to sign in with: ```bash TOKEN=$(curl -s -X POST http://127.0.0.1:/auth/login \ -H 'content-type: application/json' \ -d '{"username":"","password":""}' | jq -er .accessToken) curl -s -H "Authorization: Bearer $TOKEN" \ http://127.0.0.1:/apps//api// | jq . ``` The token lasts minutes and the refresh token rotates on every use, so a long-running client signs in again rather than holding one token. A browser dashboard uses [`passwordAuth()`](https://github.com/tetherto/mdk/blob/main/ui/packages/ui-foundation/README.md#sessions) instead of signing in this way. A Gateway started with no Kernel and no credential falls back to an `authVerifier` for routes marked `"auth": true`: ```js await startGateway({ kernelKey: false, gatewayKeyFile: './run/gateway.key', apps: [{ name: 'fleet', appDir, keyFile: './run/fleet.key' }], // route is { id, method, path, app, permissions } from the manifest authVerifier: async (request, route) => { const token = request.headers.authorization?.replace('Bearer ', '') if (!token) return false return { user: { userId: await validateToken(token) } } } }) ``` The controller then reads the verified identity: ```js module.exports = async function protectedRoute (req) { const { sub, groups } = req._info // Who asked. Kernel calls through req.mdk act for them; config.mdkClient calls act as the app (app:). } ``` 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-app-demo) 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) - [Outbound UDP to the HyperDHT bootstrap nodes](/reference/environment#requirements) ### 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 calls the corresponding method on `req.mdk`, the request's Kernel client. For a signed-in user it acts for that user: the Kernel verifies their token, checks both the app's grant and the user's own `spec.rbac` rules, and records the user as the voter. 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 | `req.mdk` 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 └── controllers/ ├── query.js ├── push.js ├── push-batch.js ├── vote.js └── cancel.js ``` ### Example controller (push.js) ```javascript 'use strict' module.exports = async function pushAction (req) { // req.mdk acts for the user the Gateway verified: Kernel checks their rules and the app's return await req.mdk.pushAction({ query: req.body.query, // Device query/selector action: req.body.action, // Action name from worker contract params: req.body.params, // Action parameters voter: req._info.sub, // Required by the payload; Kernel records the verified user authPerms: [] // Required by the payload; spec.rbac decides }) } ``` ### 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. Calls through `config.mdkClient` act as the app alone: the Kernel checks only the app's grant and records the app as the voter. Use `req.mdk` for anything a user asked for, so their own `devices: write` and `actions` rules gate the 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). Request through `mdkFetch`, which puts the signed-in user's access token on every call and parses the JSON body: a bare `fetch` sends no token, and the route answers `401`. Invalidate the queue after each write so the list refreshes: ```tsx const QUEUE_KEY = ["actions", "voting"]; type PendingAction = { id: string }; 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. `mdkFetch` parses the body // and returns it as the type you give it, so name that type or `pending` arrives as `unknown`. queryFn: () => mdkFetch(`${apiBaseUrl}/auth/actions`), refetchInterval: 10_000, }); const vote = useMutation({ mutationFn: ({ id, approve }: { id: string; approve: boolean }) => mdkFetch(`${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) => ( )); } ``` 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 Workers catalogue](/reference/supported-workers). 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, or a [private bootstrap](/guides/deployment/private-dht) on an air-gapped machine 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 the [Supported Workers catalogue](/reference/supported-workers) - 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, push a `registerThing` write action through the Kernel instead. The Kernel routes it to the Worker named in `params[0].workerId` and runs it once the action has the approvals that Worker requires: ```js const { createMdkClient } = require('@tetherto/mdk-client') const { name, privateKeyPem } = require('./.mdk/credentials/agents/provisioner.json') const client = createMdkClient({ kernelKey: kernel.getPublicKey(), kernelCredential: { name, privateKey: privateKeyPem } }) await client.connect() await client.pushAction({ query: {}, action: 'registerThing', voter: 'provisioner', authPerms: ['miner:w'], params: [{ workerId: 'avalon-rack-1', id: 'AV-002', info: { container: 'site-1', serialNum: 'AV-002' }, opts: { address: '192.168.1.31', port: 4028, password: 'admin' } }] }) ``` `registerThing` is a write action, so the Kernel evaluates it against `spec.rbac` before any Worker sees it. `mdk sa add provisioner` writes the credential this reads, and the account needs `actions: write` to push, plus `devices: write`. [Provisioning a device](/guides/security#provisioning-a-device) covers how to scope that grant and why a successful push is not yet a live device. `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:`. A machine with no outbound access runs the [local and air-gapped profile](/guides/deployment/private-dht) instead: a private bootstrap on `127.0.0.1` that every component joins. 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) MDK enforces user identity and an access policy at every entry point, and it fails closed: a stack whose `spec.rbac` binds nobody refuses every call. That policy, TLS, network isolation and secrets are still part of the site you build. Treat `mvp-site` and per-family snippets as boot demos, not a production template. ## 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. Every caller signs in to the Kernel's [token issuer](https://github.com/tetherto/mdk/blob/main/backend/core/identity/README.md) and gets a short-lived access token. One policy, `spec.rbac` in `mdk.yaml`, decides what that identity may do, and the Gateway (users), the MCP server (AI agents) and the Kernel (apps and every other caller) evaluate it with the same [RBAC library](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md). Your site owns the policy's content and the network it runs on. This page presents what to do, the options MDK gives you, and a suggested default. It covers: - [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) - [Policy and Gateway identity](#step-4-write-the-policy-and-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. ## What MDK enforces and what you own Build around these. They are current `0.y.z` behavior. | Layer | What MDK does | What you own | | ------- | -------------------------------------------------------------------------------------- | ------------------ | | Policy | `spec.rbac` in `mdk.yaml` is the only place policy is written. Default deny, no superadmin (`admin` is an ordinary role holding wildcards), an invalid policy refused at load | The roles and bindings, reviewed like code | | UI | `passwordAuth()` signs in through the Gateway, keeps the access token in memory and the refresh token in an `HttpOnly` cookie | HTTPS, a real `apiBaseUrl`, hiding write controls the user cannot use | | Gateway | Serves `/auth/*` from the Kernel's token issuer. Checks a bearer token and a `spec.rbac` rule on every app route; no route is open. Host allowlist (loopback), rate limit, Helmet headers, body limit on by default | TLS in front, `allowedHosts`, `trustProxy` behind a proxy you control | | App runtime | Runs one app's controllers in its own process and admits only the Gateway's and MCP server's keys. Calls Kernel as service account `app:`, holding only the grant its `mdk-app.yaml` `permissions` requested, and acting for the signed-in user through `req.mdk` so Kernel checks the user too | Reviewing an app's code and requested grant before installing it, narrowing the grant | | Kernel | Admits a key to connect (allowlist or trusted-keys dir), which grants nothing. Checks the access token on every request but sign-in against `spec.rbac`, per action and per device. Records every `command.request` attempt | Admitting the right keys, the policy, guarding the Kernel store | | Workers | Noise transport and key-addressed HRPC. Admit only the Kernel's client key, so every device call passes the Kernel's checks and write-command log | Network isolation, device secrets, payload checks | | MCP / agent | `POST /mcp` on `127.0.0.1`. Each agent signs in as its own service account and lists only the tools its rules allow | Agent accounts and their bindings, human approval for writes | | Devices / pools | Vendor protocol APIs and authentication | Device-network isolation | Consumers enter through the Gateway; AI agents enter through the standalone MCP server. The browser never holds a Kernel key. Direct Kernel or Worker reachability is a backend-network concern, not a product feature. ## Blueprint ### Step 1: Choose a network layout Pick a deployment topology before you write policy. Isolation is the first control; identity and policy sit 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, and the trusted-keys dir is the admission boundary. No DHT. | | 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, app runtimes, MCP server and Workers, 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 a [Kernel allowlist](#step-2-admit-the-gateway-to-kernel) that names every caller key. 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 [Admission](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#caller-admission-and-authorization) decides which keys may connect to Kernel. It is fail-closed (an empty allowlist admits nobody) and it grants nothing: what an admitted caller may do comes from its access token and `spec.rbac`. There are no allowlist roles. An entry written as `{ key, roles }` is refused at start with `ERR_KERNEL_CALLER_ROLES_UNSUPPORTED`, and `allowAll` on the Kernel with `ERR_KERNEL_ALLOW_ALL_REMOVED`. #### Options | Option | When to pick it | Trade-off | | --------------- | ---------------------- | ------------------------------------------------------------------------------------------------ | | In process (`startGateway({ kernel })`) | Single process, or `mdk run` | The Gateway key is admitted for you. Nothing to distribute | | [Trusted-keys dir](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md#trust-and-admission) | Separate processes on one machine | The Kernel admits every `.pub` in the dir. Anyone who can write the dir is admitted, so keep it `0700` and owned by the service user | | Allowlist each key (`hrpc.whitelist`, `kernel.allowKey()`) | Every multi-host site | You distribute the Gateway, MCP server and app runtime keys yourself, and update the list when a key rotates | Suggested default across hosts: one persistent key per caller, each hex public key on the Kernel's allowlist: ```js const kernel = await getKernel({ hrpc: { whitelist: ['', '', ''] } }) ``` The Gateway derives its caller key from a seed persisted in its store, so the key survives restarts: read it from `status.rpcClientKey` in the Gateway status file. The standalone MCP server (`mdk run mcp`) and each app runtime have their own persistent keys too; `mdk run` publishes them to the trusted-keys dir (`mcp.pub`, `app-.pub`). Treat each of those stores with the same care as a TLS key, since a copy of it presents that caller's transport identity. Admission alone gets a caller nowhere. A Gateway or MCP server in its own process also signs in as its service account (`mdk-gateway`, `mdk-mcp`) to read the policy it enforces, which needs `policy: read`; `mdk rbac init` binds both to a `gateway` role holding exactly that. Each app runtime signs in as `app:`. A service account's token is bound to the transport key that signed in, so a copied token is refused from any other key. Kernel admits Workers from the DHT topic the same way: only 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; it is for throwaway local experiments only and logs a warning. Without it, a peer with the topic could register as a Worker and receive the commands, including credentials, for its device family. Workers are fail-closed too. Give each Worker the Kernel's client key (`kernel.getClientPublicKey()`, not the listener key) as its only `allowedKeys` entry, so every command goes through Kernel's checks and [write-command log](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#write-command-log). Do not add the Gateway's key: an app reaches devices through Kernel, never by dialing a Worker. 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#caller-allowlist) answers only the keys in its `allowedKeys`, and `mdk run` and the `@tetherto/mdk-core` helpers admit the Kernel's client key and no other. A Worker therefore takes device calls only through Kernel, where `spec.rbac` and the write-command log apply. The device network is still yours to isolate: a Worker's key protects the Worker, not the miner behind it. [Security boundaries](/concepts/security-boundaries) give the trust-boundary rationale behind the controls below. #### Options | Option | When to pick it | Trade-off | | ---------------------------------------------------- | --------------- | --------------------------------------------------------------------------- | | Workers only on the backend network, `allowedKeys` holding the Kernel client key | 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) | | `allowAll` on `WorkerRuntime` (`MDK_WORKER_ALLOW_ALL=1`) | Throwaway local experiments only | Any peer that learns the Worker key reaches devices with no policy check and no log entry. `mdk run` never sets it | 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 and params 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: Write the policy and put identity in the Gateway `spec.rbac` in `mdk.yaml` is the only place policy is written. The Kernel applies it at every start and on `mdk rbac apply`; a policy that fails validation is refused and the one in force stays. A rule names resources, verbs and, optionally, `resourceNames`; a binding ties a role to `User`, `Group` or `ServiceAccount` subjects. The [RBAC vocabulary](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md#vocabulary) lists every resource, including `devices` (named `family/brand/worker/device`), `actions`, `commands`, `policy`, `identities` and app routes (`/` with `get`, `list`, `create`, `update`, `delete`). A package never decides who may call it. An app only requests its own Kernel access in `mdk-app.yaml` (`permissions`, required); `mdk add app`, `mdk create app`, `mdk onboard` and `mdk rbac init` write that request as role `app-` bound to service account `app:`, and the role is yours to narrow from then on. `mdk validate app ` fails when the grant is missing, differs from the request or binds nobody. Who may call the app's routes is set by your own roles. The Gateway is the user identity seam. It serves `POST /auth/login`, `POST /auth/token`, `POST /auth/refresh`, `POST /auth/logout` and `GET /auth/me` from the Kernel's token issuer. A client that keeps no cookies, such as the CLI or a script, sends `"refreshIn": "body"` at login and gets its refresh token in the body instead, which the [Gateway's sign-in routes](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md#security-model) cover along with the rest of the cookie behavior. On every app route it checks a bearer access token and a `spec.rbac` rule for the route's resource and verb (`401 ERR_AUTH_UNAUTHORIZED`, `403 ERR_AUTH_FORBIDDEN`) before body validation, the cache or the app. A route that declares `resourceName` (from a param, query or body field) is checked for that instance. The app receives the verified identity, `{ kind, sub, email?, groups? }`, as `req._info`, never the token. The [Gateway apps guide](/guides/gateway/apps#auth-and-permissions) covers the route fields. #### Options | Option | When to pick it | Trade-off | | --------------------------------------- | --------------- | ------------------------------------- | | [`mdk rbac init`](https://github.com/tetherto/mdk/blob/main/packages/cli/README.md#access-policy) | Every stack, and after `mdk add app` | Binds `mdk-cli` to `admin`, `mdk-gateway` and `mdk-mcp` to `gateway` (`policy: read`), and writes each app's grant. A role you edited is left alone | | [`mdk user add --admin`](https://github.com/tetherto/mdk/blob/main/packages/cli/README.md#users-and-service-accounts) | The first operator | Writes the `admin` role if missing and binds the user. Keep the number of admins small; `admin` holds every wildcard | | Narrow roles bound to `Group` subjects (`mdk user add --group`) | Teams with different duties (read-only staff, one rack's technicians) | More roles to review. `resourceNames` such as `rack-3-*` keep a technician to one rack | | A custom `authVerifier` with no authority | A Gateway started with neither a Kernel nor a credential | Not checked against `spec.rbac`, and refused in `mdk.yaml` (`spec.gateway.config.authVerifier`). Outside the supported path once a stack has a policy | `resourceNames` bounds which devices an identity may address, not whose data it may see. A `stat-*` aggregate log is shared: a read is authorized against the device it is addressed to and returns the stored series without narrowing it to that device, so an identity scoped to one device can read a total that other devices contributed to. A tag the addressed device does not carry is still refused (`ERR_LOG_TAG_FORBIDDEN`), and the other devices stay unaddressable. Where tenants must not see each other's figures, separate them by Worker so they share no aggregate, rather than by `resourceNames` alone. Suggested default: run `mdk rbac init` before the first `mdk run` (a `spec.rbac` that binds no user and no group, and whose bound subjects include nobody who may write the policy, makes `mdk run` refuse to boot the Kernel, which could not be handed a policy afterwards), create the first operator with `mdk user add --admin`, then give everyone else a narrow role bound by group. `mdk user add` needs a running Kernel, so the order is `mdk rbac init`, then `mdk run kernel`, then `mdk user add`. Read the effective policy with `mdk rbac show` after every change, and keep `mdk.yaml` in review like code. Single sign-on federation is not built in: users and passwords live in the Kernel's identity store. ```yaml spec: rbac: enforcement: deny roles: - name: viewer rules: - resources: ["*"] verbs: [get, list] - name: rack3-tech rules: - resources: [ops-center/miners/command] verbs: [create] resourceNames: ["rack-3-*"] bindings: - role: viewer subjects: [{ kind: Group, name: ops-team }] - role: rack3-tech subjects: [{ kind: User, name: tech@example.com }] ``` 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. Also, on the Gateway: - Terminate TLS in front of it. The refresh cookie is `Secure` everywhere but plain-HTTP loopback, so over plain HTTP on a network the browser keeps no refresh cookie and every reload signs the user out - Set `trustProxy` only behind a TLS proxy whose hop count you trust: it makes the Gateway see `https`, and without a trusted proxy client IPs (and so rate limits) can be spoofed - List the names clients use in `allowedHosts`; only loopback on the bound port passes the Host check by default - Tune `rateLimit` (default 300 requests per minute per IP). It is counted before the auth check, so failed sign-ins are throttled too - Ship the Gateway's `authz` decision lines (subject, route, resource, verb, decision, policy version) to your log store - 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 ### Step 5: Authorize writes Kernel checks every write against `spec.rbac`, whichever path it takes. A `command.request` needs `devices: write` on its target device, and a command scoped to a worker or rack needs it on every device that worker owns. Every attempt, refused ones included, is recorded in the [write-command log](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#write-command-log) with the caller's subject and the roles `spec.rbac` binds to it; reading it with `command.log` needs `commands: read`. When an app acts for a signed-in user (`req.mdk`), Kernel verifies the user's own token, requires both the app's and the user's rules to allow the write, and records the user as the caller via the app. #### Options | Option | When to pick it | Trade-off | | ------------------------------------- | --------------- | --------------------------------------- | | Direct command ([`command.request`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#commanddispatcher)) | Low-impact, operator-in-front actions (LED, a single-device reboot) | Fast. One identity's `devices: write` rule is the only gate | | 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. A push needs `actions: write` and `devices: write` on every device it resolves to. A vote is checked against the stored action's target devices with the voter's own `devices: write` rule. 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`](https://github.com/tetherto/mdk-app-agent)) | The model cannot execute a write until someone approves | For a caller with a token, the voter and requester are the operator it acts for (`req.mdk`), else the caller's own identity, and the payload's self-asserted `authPerms` are ignored on both push and vote; they apply only to in-process calls. Naming another voter without the operator's token needs `actions: delegate`. Prefer `req.mdk`, which needs no `delegate` grant, and grant `delegate` only to an app whose code you trust to name the right user. #### Provisioning a device Registering, updating or forgetting a device is a write action (`registerThing`, `updateThing`, `forgetThings`) pushed through the Kernel with the target Worker in `params[0].workerId`. It needs `actions: write` to push, and `devices: write` on every device the push resolves to. The payload requires `voter` and `authPerms` (`ERR_PAYLOAD_VOTER_REQUIRED`, `ERR_PAYLOAD_AUTH_PERMS_REQUIRED`), so a push without them is refused before its targets are resolved. For a caller carrying a token the Kernel then ignores what they assert and evaluates the caller's own identity and rules instead, so they satisfy the schema rather than grant anything. `updateThing` and `forgetThings` name a device the registry already knows, so a `devices: write` rule narrowed with `resourceNames` matches them normally. `registerThing` does not: the device is not in the registry yet, so it resolves to no instance name, and a rule scoped to instances never matches a request that names none. Registering needs an unscoped `devices: write` rule, which grants write access to every device the policy covers. Bind that to a dedicated provisioning account rather than to an operator role, and keep the scoped rule for everything else. A push that resolves and executes means the configuration is persisted. The running Worker does not publish the new device: it appears after the Worker restarts against the same store. Suggested default: approval-gated writes for anything that changes hash, power, or network membership, with the people who vote bound to a role holding `actions: vote` and `devices: write` on the affected devices. Direct commands only where a single operator's `devices: write` rule is enough. Keep `commands: read` with the people who audit. ### Step 6: Wire the UI session [`passwordAuth()`](https://github.com/tetherto/mdk/blob/main/ui/packages/ui-foundation/README.md#sessions) from `@tetherto/mdk-ui-foundation` signs a user in through the Gateway's `/auth/login`. The access token stays in memory; the refresh token stays in an `HttpOnly`, `SameSite=Strict` cookie scoped to `/auth`, out of reach of page scripts. The provider renews the token on a timer and on a `401`, and tabs refresh one at a time under a Web Locks lock. Refresh tokens rotate on every use, and a used one presented again revokes the whole session (theft detection). The one exception is two clients refreshing at once: within about 10 seconds of its use, while its successor is still unused, a used token gets that same successor, so both hold one refresh token and the next reuse of it, by whoever is behind, revokes the session. #### Options | Option | When to pick it | Trade-off | | --------------------------------------- | --------------- | ------------------------------------- | | `passwordAuth({ baseUrl })` | Every dashboard on an MDK stack | Needs TLS on a network, or the browser keeps no refresh cookie. You own the sign-in page | | `bearerTokenAuth()` | A client that already holds an access token (a script, a kiosk handed a token) | No refresh; the session ends on `401` | | `noAuth()` | Fixture-backed demos | No token. Every app route on a real stack answers `401` | | Custom `AuthProvider` | A session model the presets do not cover | You implement `getToken`, refresh, and sign-out | Suggested default for a production dashboard: - `passwordAuth({ baseUrl: httpsOrigin })` passed to ``, with `restore()` before the first render - HTTPS end to end, a real `apiBaseUrl`, no Vite dev proxy - One fetch client (the provider's `fetcher`) so pages cannot skip `Authorization` - Route guards on any view that can mutate the fleet (the shell template uses ``) - Hide write controls the user's roles do not allow. Hiding is not authorization; the Gateway and Kernel still deny - Static bundle behind the same proxy that terminates TLS. Do not run `vite dev` as the production UI ### Step 7: (Optional) MCP and the operator agent MCP is optional. The standalone [`@tetherto/mdk-mcp`](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md#ai-agents-and-the-mcp-server) process binds `127.0.0.1` and answers `POST /mcp`. With an authority (an in-process Kernel, or `mdk run mcp` signing in as `mdk-mcp`), every request needs an agent's own access token, each agent's `tools/list` is filtered by its rules, and each call is checked as the Gateway checks the same route. A tool scoped by an instance name is listed when some instance is allowed and checked per call; a tool with no declared access is not offered. The static `spec.mcp.token` and `spec.mcp.identity` are refused. #### Options | Option | When to pick it | Trade-off | | ---------------- | --------------------------- | ------------------------------------------------- | | No MCP, no agent | Dashboards and scripts only | Smallest surface | | One service account per agent on loopback | Any agent | `mdk sa add `, a binding in `spec.rbac`, then `mdk sa token ` (or `ServiceAccountTokenSource`) for its bearer token. The agent reaches apps as itself | | Operator agent app on the Gateway | In-process chat for people who already signed in | Keep `approvalTimeoutMs` on. Chat sessions bind to the signed-in user's `sub`, so one operator cannot read another's | | MCP published past localhost | An agent runtime on another host | Put TLS in front and add the name to `allowedHosts`. The token check still applies; the transport is yours | Suggested default: leave MCP on `127.0.0.1`, give each agent its own service account with a read-only role, and grant write tools only with `allowWrite` or `allowTools` plus a rule that allows them. 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 on the Gateway HTTP port only | Insufficient | The Gateway keeps serving HTTP while the Kernel is unreachable; Kernel-backed calls fail with `ERR_MDK_CLIENT_UNAVAILABLE` until it is back. | Suggested default: - Mode `0700` (or equivalent) on the Kernel db directory, `.mdk/`, the trusted-keys dir and Worker `storeDir`, and `0600` on seed, key and credential files. The Kernel store holds users, password hashes and the token signing key; `.mdk/credentials/` holds every app's and agent's private key. Encrypt at rest if the host requires it - Back up those directories as operational data, with the same access control as the live files - When staff leave, `mdk user disable` or `mdk user remove` them: their access tokens stop at the Kernel at once, and their bindings stay until you remove them from `spec.rbac` - Rotate service-account keys with `mdk sa rotate`, and Kernel, Gateway and Worker transport keys when a host is rebuilt, updating the allowlist in the same change - Practice recovery from a wiped data directory before an incident - Watch Gateway-to-Kernel errors and the Gateway's policy version, 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 an admission in running processes. Use `kernel.revokeKey(key)` and each affected `runtime.revokeKey(key)`, and remove the publication and startup configuration that would admit it again. Check the live Kernel and Worker allowlists, not just files on disk, and verify that refused calls fail after revocation and after restart. ## Trust boundaries and known limits These hold by design. Plan the site around them. | Boundary or limit | What happens | What to do | | ----------------- | ------------ | ---------- | | App routes are a confused-deputy boundary | The Gateway checks the user against the route's rule and forwards their token. A controller calling Kernel through `req.mdk` acts for that user: Kernel checks both `app:` and the user. A controller calling through `config.mdkClient` acts as the app alone, and Kernel does not re-check the user | Use `req.mdk` for work done for a request, keep each app's grant narrow (`resourceNames`), and review app controller code like any privileged service | | Policy at a remote Gateway | A Gateway or MCP server in its own process polls the policy every 5 seconds. A failed poll keeps the last good policy, so a Kernel blip does not lock the site, but for 60 seconds at most (`maxStaleMs`): past that it allows nothing until a poll succeeds. Kernel switches at once | Watch for failing polls; run the Gateway in the Kernel's process where an instant switch matters | | Revoked access tokens at the edge | A password change, disable, delete, group or email change, or new service-account key refuses the subject's tokens at once wherever the issuer verifies: every Kernel request, and an in-process Gateway or MCP server. A Gateway or MCP server in another process polls every subject's token version with the policy, so there a revoked token keeps working until the next poll (up to 5 seconds); Kernel refuses it at once | Treat the edge window as one poll interval. Kernel-backed calls stop immediately | | Login lockout is in memory | Failed sign-ins lock a user name (5 failures, doubling from 30 seconds up to 15 minutes), counted in the Kernel's memory; a Kernel restart clears it | Rate-limit `/auth/login` at the proxy too, and alert on repeated failures in the `authz` log | | Service-account assertions | An assertion lives at most 60 seconds and is single use, remembered in the store across restarts | Keep agent and app private keys on the host that owns them | ## Decision cheat sheet | Decision | Suggested production choice | Demo-only choice | | ---------------- | ------------------------------ | --------------------------------------------- | | Topology | Microservices over DHT, three networks (operator / backend / devices) | Single process on loopback | | Kernel admission | Allowlist of persistent Gateway, MCP and app runtime keys; trusted-keys dir on one machine | In process through `mdk run` | | Worker access | Backend network only, `allowedKeys` holding only the Kernel client key | `allowAll` on a throwaway local Worker | | Policy | `spec.rbac` from `mdk rbac init`, narrow roles bound by group, reviewed like code | One `admin` binding | | User identity | `mdk user add --admin` for the first operator, Gateway `/auth/*`, TLS | Plain HTTP on loopback | | Writes | Approval-gated for fleet changes; voters hold `devices: write` on the targets | Direct `command.request` as an admin | | UI | HTTPS, `passwordAuth()`, route guards | Vite proxy, `noAuth()` on fixtures | | MCP / agent | Loopback, one service account per agent, read-only role, human approval | Disabled | | Secrets | Secret manager into the process, `.mdk/` and stores `0700` | Example `admin` passwords | ## Next steps - [Security boundaries](/concepts/security-boundaries): what each layer trusts, and what it does not - Control plane: read path, direct command, and approval-gated write - [RBAC policy model](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md): rule shape, subjects, and the full vocabulary - [Token issuer](https://github.com/tetherto/mdk/blob/main/backend/core/identity/README.md): access and refresh tokens, sessions, and when tokens stop working - [Access policy commands](https://github.com/tetherto/mdk/blob/main/packages/cli/README.md#access-policy): `mdk rbac init`, `apply` and `show` - [Users and service accounts](https://github.com/tetherto/mdk/blob/main/packages/cli/README.md#users-and-service-accounts): `mdk user`, `mdk sa`, and `mdk login` - [Route rules in app controllers](/guides/gateway/apps#auth-and-permissions): `verb`, `resourceName`, and `req._info` - Deployment topologies: this blueprint assumes microservices; local and single-process are smaller footprints - [Hosted-site reference configuration](/guides/deployment/hosted-site): two profiles and one settings table - [Security policy](https://github.com/tetherto/mdk/blob/main/SECURITY.md): report product vulnerabilities through a 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 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 mock 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 { "contractVersion": "1", "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 85°C 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: - `contractVersion: "1"` holds the contract to the v1 requirements as errors: every numeric telemetry entry declares a `unit` from the schema's closed vocabulary (`TH/s`, `W`, `kW`, `J/TH`, `°C`, `%`, `rpm`, `V`, `A`, `Hz`, `s`, `count`), and every `number` param declares `min` and `max`. Without the stamp the same gaps are warnings only. - `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. Every Worker also answers a `health` channel on every device, with no contract entry required: what the device's last metrics poll saw, as `{ reachable, lastSuccessAt, lastError, lastErrorAt }` (never `env`, `opts` or `config`, which can hold secrets and device credentials). `reachable: false` on a `READY` Worker is "Worker up, device silent". Declaring a `health` entry in [`capabilities.telemetry`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) yourself overrides it with your own handler, which should answer the same fields. ```js await mdkClient.pullTelemetry(deviceId, 'health') // → { name: 'health', value: { reachable: true, lastSuccessAt: 1792505336310, lastError: null, lastErrorAt: null } } ``` ### 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 mock 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 - [Outbound UDP to the HyperDHT bootstrap nodes](/reference/environment#requirements) ### Check the package Run the [`mdk` CLI](/guides/cli) 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-kernel ``` `@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. `@tetherto/mdk-kernel/testing` supplies the service account that client [signs in with](#giving-a-test-script-an-identity). ### 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 { grantTestAdmin } = require("@tetherto/mdk-kernel/testing"); 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 } }, ], }); // Allow the Kernel's client key and no other, so every device call goes // through the Kernel's policy check and write-command log. 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 twice: admit this script's caller key so it may // connect, then sign in as a service account the policy binds to a role. const caller = callerKeyPair(path.join(ROOT, "caller")); kernel.allowKey(caller.publicKey); const credential = await grantTestAdmin(kernel); client = createMdkClient({ kernelKey: kernel.getPublicKey(), kernelKeyPair: caller, kernelCredential: credential, }); 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. #### Giving a test script an identity Every HRPC request the Kernel serves needs an access token, so a client that only knows the Kernel's key gets `ERR_KERNEL_TOKEN_REQUIRED`. `@tetherto/mdk-kernel/testing` is the supported way to get one in a test. `grantTestAdmin(kernel)` registers a service account on a running Kernel, binds it, and returns the `{ name, privateKey }` that [`createMdkClient`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md#createmdkclientconfig-opts--auto-connecting-client) takes as `kernelCredential`: ```js const { grantTestAdmin } = require("@tetherto/mdk-kernel/testing"); const credential = await grantTestAdmin(kernel); const client = createMdkClient({ kernelKey: kernel.getPublicKey(), kernelCredential: credential, }); ``` The role it binds holds every resource and verb, and applying it replaces whatever policy the Kernel already had: only other subjects already bound to `test-admin` carry over. Point it at a throwaway store, never at a running site, which writes its policy in `spec.rbac` and binds the narrowest role each caller needs ([access policy](https://github.com/tetherto/mdk/blob/main/packages/cli/README.md#access-policy)). ### 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 client key and no other key in // the dir, 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, and only that 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 ### Workers - **[Worker reference](/reference/worker/)**: device protocol adapters that wrap vendor hardware APIs and expose them through the MDK Protocol - **[Supported Workers](/reference/supported-workers/)**: the miner, container, power meter, sensor and mining pool Workers you can run today, partner-maintained and MDK-maintained ## 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) - Outbound UDP to the HyperDHT bootstrap nodes, the [public set on port `49737`](/guides/deployment/hosted-site#networks-and-ports) by default or your own private list, because [HRPC runs over HyperDHT in every deployment shape, even on one host](/reference/glossary#hrpc-on-the-same-host). A network that blocks this traffic [hangs before the Kernel key is printed](/guides/miners/troubleshooting#example-does-not-print-a-kernel-key). Across hosts (the [DHT discovery shape](/guides/deployment/run-all-workers-site)), the backend hosts also need [hole-punched UDP](https://github.com/holepunchto/hyperdht) to each other, as the [hosted-site port table](/guides/deployment/hosted-site#networks-and-ports) lays out. 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 several third-party Workers as git dependencies, each pinned to a commit in a vendor-controlled repository. Its [`package.json`](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/package.json) carries the current set. 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) - [Access control](#access-control-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) | | **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` | [`mdk-app-demo`](https://github.com/tetherto/mdk-app-demo) | | **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 | [`mdk-app-demo/mdk-plugin.json`](https://github.com/tetherto/mdk-app-demo/blob/main/mdk-plugin.json) | | **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) | | **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, as in the [local and air-gapped profile](/guides/deployment/private-dht) | [`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. ## Access control terms This section explains the vocabulary of the access control model: who a caller is, and what decides whether a call is allowed. | Term | What it is | Lives at | | --- | --- | --- | | **Access token** | The short-lived EdDSA-signed JWT a signed-in caller presents on every Kernel request past sign-in, and on every Gateway app route. It carries who the caller is, never what they may do. | [`backend/core/identity/`](https://github.com/tetherto/mdk/blob/main/backend/core/identity/README.md) | | **Binding** | The link from a role to the subjects that hold it | [`backend/core/rbac/`](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md) | | **Default deny** | The policy's only enforcement mode: no matching rule, no bound role, or no policy at all is a refusal, and `admin` is an ordinary role holding wildcards rather than a superadmin | [`backend/core/rbac/`](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md) | | **Policy** | `spec.rbac` in `mdk.yaml`, the only place access rules are written. The Kernel applies it at boot and on `mdk rbac apply`, and the Gateway, MCP server and Kernel evaluate it with one shared evaluator. | [`backend/core/rbac/`](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md) | | **Refresh token** | The longer-lived token that buys a new access token. It rotates on every use, and presenting one that has already been used ends the whole session. | [`backend/core/identity/`](https://github.com/tetherto/mdk/blob/main/backend/core/identity/README.md) | | **Resource** | What a rule grants access to: a Kernel resource such as `devices`, `workers`, `commands`, `actions`, `policy` or `identities`, or an app route as `/` | [`backend/core/rbac/`](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md) | | **Resource name** | The instance a rule narrows to, written `family/brand/worker/device` for a device, `family/brand/worker` for a Worker, and taken from a declared route field for an app route. A rule carrying `resourceNames` never matches a request that names no instance. | [`backend/core/rbac/`](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md) | | **Role** | A named set of rules in the policy | [`backend/core/rbac/`](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md) | | **Rule** | One grant inside a role: the resources it covers, the verbs it allows on them, and the instances it narrows to when it names any | [`backend/core/rbac/`](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md) | | **Service account** | A non-human identity that signs in with its own Ed25519 key: `app:` for an app, and a name of its own for an AI agent or for the CLI, Gateway and MCP server. `mdk sa add ` creates an agent's; an app's comes with `mdk add app`, and the CLI, Gateway and MCP server get their own. | [`backend/core/identity/`](https://github.com/tetherto/mdk/blob/main/backend/core/identity/README.md) | | **Subject** | Who a binding names: `User` matches a user token's name or email, `Group` matches an entry in the token's `groups`, and `ServiceAccount` matches a service account's own name | [`backend/core/rbac/`](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md) | | **Token issuer** | The Kernel component that signs users in by password and service accounts by signed assertion, issues their tokens, and publishes the JWKS every verifier reads | [`backend/core/identity/`](https://github.com/tetherto/mdk/blob/main/backend/core/identity/README.md) | | **Verb** | The operation a rule allows on its resources, such as `write` on `devices` or `create` on an app route. A verb applies only to its own domain, so the validator rejects a rule whose verb fits none of its resources. | [`backend/core/rbac/`](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md) | The [Kernel resource and verb table](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md#vocabulary) gives every resource and the verbs it accepts, and the [site security blueprint](/guides/security) walks through provisioning identities and writing the policy that binds them. ## 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 - [`KernelAuthorizer`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#caller-admission-and-authorization): verifies the access token on every HRPC request but sign-in, and evaluates its identity against the policy in force, once per device or Worker the request touches - [`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 - [`CommandLog`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#write-command-log): records every command request, accepted or refused, with its caller, target, params and outcome - [`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. A release publishes it to npm once it also sets `publishConfig.access` to `public` and its `repository` ## 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-identity`](https://github.com/tetherto/mdk/blob/main/backend/core/identity/README.md) | MDK identity: the token issuer (users, service accounts, rotating refresh tokens) and JWT verification for the Gateway, MCP server and Kernel | `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-rbac`](https://github.com/tetherto/mdk/blob/main/backend/core/rbac/README.md) | MDK RBAC policy library — validates spec.rbac from mdk.yaml and evaluates callers at the Gateway, MCP server and Kernel | `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` | ### Plugins and tooling | Package | Purpose | Availability | | --- | --- | --- | | [`@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-workers) - 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. Each app MDK ships lives in its own repository, is offered through [`mdk onboard`](/guides/cli#command-groups), and is declared by its package name (`@tetherto/mdk-app-`). The Gateway serves an app's routes under `/apps/` unless the app's `spec.apps` entry sets `mount`; `mount: /` serves them at the Gateway root. ## Route tables | App | Package | Repository | Mount | | ------- | ------------------------- | ------------------------------- | ---------------------------------------------------------------- | | `agent` | `@tetherto/mdk-app-agent` | [`mdk-app-agent`](https://github.com/tetherto/mdk-app-agent) | `/`, because its UI calls `/agent/sessions…` at the Gateway root | | `demo` | `@tetherto/mdk-app-demo` | [`mdk-app-demo`](https://github.com/tetherto/mdk-app-demo) | The default, `/apps/demo` | Each repository's README carries the app's route table, configuration, and errors. ## Next steps - [Declare an app in your stack](/guides/gateway/apps): the manifests, controllers, and how a stack runs them # Supported Workers (/reference/supported-workers) ## Partner-maintained Workers ### Miners } title="Antminer" href="https://github.com/bitmain-official/antminer-mdk-worker" description="bitmain · Not enumerated; see manufacturer documentation" /> } title="LuxOS" href="https://github.com/LuxorLabs/luxos-mdk-worker" description="luxor · Not enumerated; see manufacturer documentation" /> } title="Proto" href="https://github.com/block/proto-tether-mdk-worker" description="proto · R2" /> } title="SealMiner" href="https://github.com/sealminer/sealminer-mdk-worker" description="bitdeer · A2, A3, A4" /> } title="Whatsminer" href="https://github.com/whatsminer/whatsminer-mdk-worker" description="microbt · Not enumerated; see manufacturer documentation" /> ### Mining pools } title="EMCD" href="https://github.com/emcdio/emcd-mdk-worker" description="emcd · EMCD-BTC" /> } title="Foundry USA Pool" href="https://github.com/foundryservices/foundry-mdk-worker" description="foundry · FOUNDRY-BTC" /> } title="Luxor" href="https://github.com/LuxorLabs/luxor-mdk-worker" description="luxor · LUXOR-BTC" /> } title="Ocean" href="https://github.com/OCEAN-xyz/mdk-wrk-minerpool-ocean" description="ocean · OCEAN-BTC" /> } title="SpiderPool" href="https://github.com/SpiderPool-New/spiderpool-mdk-worker" description="spiderpool · SPIDERPOOL-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" /> ## 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 `