# MDK Docs (/)
}
title={Learn what MDK is}
href="/concepts"
description={
Product overview, architecture, and the MDK packages
}
/>
}
title={Try the demo}
href="/tutorials/run-a-site"
description={
One command brings up Workers, mock hardware, a Gateway API, and a live dashboard
}
/>
I'm an AI agent |{' '}
I am building with one
>
}
subtitle="Optimize your development workflow by bridging the gap between large language models and high-performance mining infrastructure."
>
}
title={Build dashboards with your AI agent}
href="/tutorials/ui/react/build-any-dashboard-with-an-agent"
description={
Wire your LLM to MDK with the UI CLI, then build from plain-language prompts
}
/>
}
title={Full docs in one file}
href="/llms-full.txt"
description={
Every page of these docs in one plain-text file—open in the browser to copy or save
}
/>
# 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 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**. Clients — browsers and [AI agents](#ai-ready-with-unified-intelligence) alike — reach the kernel exclusively through
the Gateway, the secure entry point your team builds with the SDK. Tying everything together is a **single contract per device type**: the same
`mdk-contract.json` serves the UI (data labels), the orchestrator (validation rules), and AI agents (reasoning context).
One file, three audiences, no drift.
### The orchestration kernel
Kernel, 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 devices through a standardized language called the **MDK Protocol**, a common set of messages that every device
in the system understands, regardless of manufacturer or model. 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.
- **Multi-language support**: available for Node.js, Python, Go, and more; use whatever language your team prefers
- **Automatic connection handling**: manages reconnection, retries, and transport selection behind the scenes
- **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** 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` for React today), and a production-tested React UI Kit
(`@tetherto/mdk-react-devkit`) for dashboards.
- **Backend tools**: a plug-and-play library that drops into Fastify or Express to handle command proxying and
request-level caching, with hooks for custom routes and aggregations.
- **Plugins**: drop-in modules that pair a frontend tools widget with a backend tools route, so third parties
can ship whole features without forking the Gateway.
Using [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) without the Gateway is technically possible but not supported by this monorepo — most applications build on the Gateway.
## 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`. No involvement from MDK maintainers needed.
- **Software developers**: build custom mining applications in any language, or leverage the
MDK App Toolkit's frontend and backend tools for rapid development.
- **AI/Automation teams**: [connect intelligent agents](#ai-ready-with-unified-intelligence) that can monitor, diagnose,
and act on device issues autonomously
## 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 hosts plugins and adds request-level caching and an HTTP interface; each plugin
builds its own `@tetherto/mdk-client` and does its own fleet aggregation. Authenticating callers is left to the plugin
controllers you write. AI agents can drive the fleet through the standalone [`@tetherto/mdk-mcp`](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) package
- **MDK App Toolkit**: sits on top of the Gateway. Adds a plugin system 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.
For the full layer-by-layer view with transports and discovery flows, see the [MDK stack](/concepts/architecture#the-round-trip) on the
Architecture page.
## AI-ready with unified intelligence
MDK is designed from the ground up for AI-driven operations. 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`) 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 that AI agents can follow autonomously
This means an AI agent connecting to MDK doesn't need a separate knowledge base or custom prompts per device.
The intelligence travels with the device; the same contract that validates commands and generates dashboards also determines
how AI reasons about that hardware.
## 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 and control
- Custom analytics and reporting pipelines
- White-labeled hosted mining platforms
- Third-party device integrations and plugins
## 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. A single Gateway connects to all of them,
giving you one view across your entire operation.
- **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
- Connecting intelligent agents
# Architecture (/concepts/architecture)
## How MDK works
MDK is built around ownership, not layers. Three tiers, each owned differently:
- **Kernel is invariant.** One small coordination layer every deployment runs unchanged: it routes validated commands to
whichever Worker owns a device, and pulls telemetry back. It has no plugin system and no per-deployment configuration
of its routing behavior.
- **Extensions are yours.** Worker plugins wrap a device family; Gateway plugins add
HTTP routes, aggregation, and auth. Both are code you write and own, isolated from the kernel and from each other.
- **The UI devkit is optional.** Nothing above the Gateway is required: a deployment can dispatch commands and pull
telemetry with only `@tetherto/mdk-client` and no Gateway or UI at all.
## The round trip
```mermaid
flowchart LR
subgraph consumers [Consumers]
Agent["AI Agent"]
Dashboard["Dashboard"]
end
subgraph gw ["Gateway"]
GP["Gateway plugin\n(extension point)"]
end
K["Kernel"]
subgraph wk ["Worker"]
WP["Worker plugin\n(extension point)"]
end
Devices[("Devices")]
Agent --> GP
Dashboard --> GP
GP <-->|"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 calls a Gateway plugin's route. That plugin builds its own
`@tetherto/mdk-client` and dispatches a command through it. Kernel 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: Gateway plugin, then caller. Telemetry travels the same round trip in reverse, on demand: the Gateway plugin'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 plugin** teaches Kernel about one
device family; a **Gateway plugin** teaches the Gateway a new route. Kernel itself never changes.
## The three tiers
**Gateway**: 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) and the only tier where *user*-level authentication, aggregation,
and business logic live. Kernel's own allowlist, when configured, gates which *connections* it accepts: a transport-level
check, not a user identity.
**Kernel**: 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.
**Workers**: 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.
## Why HRPC
Every hop above (Gateway to Kernel, Kernel to Worker) speaks [Hyperswarm RPC (HRPC)](/reference/glossary#hyperswarm-rpc): an encrypted,
key-addressed peer-to-peer transport, not HTTP. A site network connects a fixed, known set of processes to each other, not
the open web; HRPC's key-based addressing means a Worker or Gateway is reachable the same way whether it sits on the same
host or across a DHT, with no separate TLS/cert story and no public-facing port to secure. The trade-off is a caller must
hold or discover the callee's public key before it can connect: there is no URL to type into a browser.
In practice: a caller sends one request and receives one response over that channel; telemetry is never pushed to a
caller or streamed: it is always a pull, whether triggered on demand by a caller or by Kernel's own scheduled cadence, and
read back through the same round trip described above. A dropped connection is the client's problem to recover from: the
next call through `@tetherto/mdk-client` reconnects; Kernel does not buffer or replay what it couldn't
deliver while the link was down.
## What's authoritative, and what's 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
plugin, dashboard, agent) is a live pull through that chain, not a read from a Kernel-side cache. See
[the storage model](/concepts/the-storage-model) for the full picture, including what Kernel's write-command log guarantees on restart.
## Next steps
- Understand [the integration model](/concepts/the-integration-model): what a Worker plugin and a Gateway plugin each get to do
- Understand [the storage model](/concepts/the-storage-model): where state actually lives as you scale
- 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
## Next steps
- Understand [the integration model](/concepts/the-integration-model): what a Worker plugin and a Gateway plugin each get to do
- Understand [the storage model](/concepts/the-storage-model): where state actually lives as you scale
- 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
# Scalability (/concepts/scalability)
## The axes
Three independent numbers describe how big an MDK deployment is:
- **Devices per Worker plugin**: how many devices one Worker instance 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**: how many Kernel instances one Gateway (and, above it, one AI agent) connects to at once.
This page is about *how many* Workers and Kernels a deployment runs, not *how those processes are packaged* on a
host (one process versus many machines). That's a [deployment topology](/guides/deployment) choice, made
independently of the numbers on this page.
## Single-kernel versus multi-kernel
The one topology distinction this page owns: does your deployment run **one Kernel serving a site**, or **several
Kernels, each serving its own site, behind one Gateway**?
- **One Kernel** is the default and the right choice until you have a concrete reason to split: a single Kernel
process routes commands and telemetry for every Worker registered to it, with no per-Worker partitioning.
- **Several Kernels**, one per physical site, is the shape for multi-site operations (for example, a Texas site
and an Iceland site). Each Kernel is fully isolated: Kernel instances do not federate registries, share queues, or
synchronize state with each other. A crash at one site has zero effect on any other. A single Gateway (and, above
it, an AI agent) connects to all of them and merges results in its own controller code: that aggregation is
Gateway-layer work, not something Kernel does for you.
## When to add a Kernel
Add a second Kernel when you're adding a second physically- or organizationally-distinct site, not because one
Kernel is running out of capacity for a single site's device count: Kernel is not currently known to bottleneck at
realistic single-site device counts (see the benchmark table below for what's actually been measured). Splitting
Kernels for a single site buys you nothing: you'd gain isolation you don't need and lose the single registry that
makes routing simple.
## What serializing Workers and Kernels means
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. 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
See [the storage model](/concepts/the-storage-model) for the full picture. In short: each Kernel keeps its own separate store: 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 normally.
- A Kernel crash is recovered from its own command write-ahead log on restart (`recover()` sweeps non-terminal
command states); it does not need to reconstruct device state, since it never owned it.
- In a multi-Kernel deployment, one site's Kernel going down has no effect on any other site's Kernel: there is no
shared state to become inconsistent.
## Benchmarks pending
A real benchmark harness exists (`backend/tests/benchmark/`) and can measure device counts, telemetry throughput,
and command latency at a given topology, but no baseline numbers are committed yet. This table reserves the shape
for when they are:
| Topology | Devices | Telemetry throughput | Command latency (p50/p99) |
| --- | --- | --- | --- |
| Single Kernel, single Worker | *pending* | *pending* | *pending* |
| Single Kernel, N Workers | *pending* | *pending* | *pending* |
| Multi-Kernel (per-site) | *pending* | *pending* | *pending* |
## 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
## Next steps
- Understand [the storage model](/concepts/the-storage-model)
- Choose a [deployment topology](/guides/deployment)
- Understand [architecture](/concepts/architecture)
# The integration model (/concepts/the-integration-model)
## Two extension points, named after what they extend
| | Worker plugin | Gateway plugin |
| --- | --- | --- |
| Extends | The Worker tier | The Gateway tier |
| Declares itself with | `mdk-contract.json` | `mdk-plugin.json` |
| Read by | Kernel (routing, validation), the UI (labels), agents (reasoning context) | The Gateway loader (routes, auth flag) |
| 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` |
The naming is literal: a **Worker plugin** is a plugin for a Worker, a **Gateway plugin** is a plugin for the
Gateway. Neither extends Kernel: Kernel has no plugin system, by design (see [Architecture](/concepts/architecture)).
## The contract: one file, three audiences
A Worker plugin's `mdk-contract.json` is read by three different consumers, none of which see a different copy:
- **Kernel** reads `capabilities.telemetry[]`/`commands[]` to validate that a command a caller sends is one the
Worker actually declared, and to route by device family for write-permission checks (`miner:w`, `container:w`).
- **The UI** reads the same telemetry/command names and units to label a value without hard-coding a device's
vocabulary.
- **An AI agent** reads the same contract at runtime to derive its tool set: a new device family means new agent
tools with no change to the agent's own code (see the MCP server and runtime tool derivation).
One file, no drift between what the kernel enforces, what the UI shows, and what an agent can reason about.
## `mdk-plugin.json` gets the same treatment, for Gateway plugins
A Gateway plugin's manifest declares its routes (`id`, `handler`, `http.method`/`http.path`, response schema,
constraints, examples, errors, `safety`). The Gateway's plugin loader reads it to mount routes and validate the
manifest shape at load time; nothing about it is hand-wired into the Gateway's own code path per plugin.
## 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 Gateway plugin per route you need) 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.
## Contract versioning today
There is no enforced compatibility mechanism between contract versions today. A Worker plugin's `mdk-contract.json`
has no version field of its own: the package's `package.json` semver is the only version signal, and nothing in
Kernel or the Gateway checks it against a caller's expectations. In practice this means: a contract's shape is
whatever the currently-loaded plugin declares, and there's no compatibility gate protecting a caller written
against an older shape. If a contract's telemetry or command names change, that's a breaking change for anything
built against the old names, with no automated warning today.
## Next steps
- [Build a Worker plugin](/guides/workers/build-a-worker) for a new device family
- Understand the [Gateway plugin authoring flow](/guides/gateway/plugins)
- 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
## Next steps
- [Build a Worker plugin](/guides/workers/build-a-worker) for a new device family
- Understand [the storage model](/concepts/the-storage-model)
- Understand [architecture](/concepts/architecture)
# The storage model (/concepts/the-storage-model)
## Where data lives
| Data | Lives where | Engine |
| --- | --- | --- |
| Live device state | The device itself | N/A: Kernel and Workers hold no independent copy of "truth" |
| 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 (see [The integration model](/concepts/the-integration-model) for what a Worker plugin controls) |
| Credentials and per-device config | Wherever the Worker plugin author put them | Worker-defined |
Kernel is intentionally not the place telemetry lives: its own store code says so directly: "Telemetry storage is
intentionally NOT here... Workers own their own telemetry storage." Kernel's Hyperbee holds three things only: the
Worker/device registry, published capabilities, and the write-command log.
## What's authoritative, and what's cached
The physical device is the one source of truth. Everything above it is a view:
- A **Worker** is the authoritative record of its own device's state: Kernel never overrides what a Worker reports.
- **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 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 (a Gateway plugin, 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, not a time-series database or a cloud store
MDK's storage choices favor **local-first, zero-external-dependency operation** over the query flexibility 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 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: see [Scalability](/concepts/scalability) for what that costs as a fleet grows.
## Retention
Kernel's command log and registry retain what they need for correctness (routing state, in-flight command
lifecycle) with no separate pruning policy documented today. Telemetry retention is entirely up to whatever a
Worker plugin's author implemented: MDK does not impose or enforce a retention window.
## 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 Gateway plugin 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 a Gateway plugin (a scheduled export, a mirror into another system) is code you write yourself against
`@tetherto/mdk-client`, the same way a plugin controller would.
## Under growth and 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 (see [Scalability](/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
## Next steps
- Understand [the integration model](/concepts/the-integration-model)
- Understand [architecture](/concepts/architecture)
- Understand [scalability](/concepts/scalability)
# What's an app? (/concepts/whats-an-app)
## Definition
An MDK app is a `mdk.yaml` declaring a Gateway with its plugins, one or more Workers, and their configuration, plus,
optionally, a UI or headless consumer built against that same Gateway.
## Anatomy
```mermaid
flowchart TB
subgraph reused ["Reused: you don't write this"]
Kernel["Kernel"]
Gateway["Gateway container"]
Devkit["UI devkit (optional)"]
end
subgraph yours ["Yours: the app"]
Spec["mdk.yaml: Worker + Gateway plugin selection, config"]
WP["Worker plugin(s)"]
GP["Gateway plugin(s)"]
UI["UI or headless consumer"]
end
Spec --> Kernel
Spec --> Gateway
WP --> Kernel
GP --> Gateway
UI --> Gateway
Gateway --> Devkit
style reused fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
```
- **`mdk.yaml`**: the deployment unit. Names the stack, ports, which Worker packages run with what device config, and
which Gateway plugins load with what config. This is what `mdk onboard`/`mdk create` write and `mdk run` reads.
- **Worker plugin(s)**: yours if you're integrating a new device family; reused if you picked one that already ships
(an Antminer worker, a demo worker).
- **Gateway plugin(s)**: yours if you need a route no existing plugin exposes; reused for anything already bundled.
- **UI or headless consumer**: optional. A scaffolded dashboard, a script calling `@tetherto/mdk-client` directly, or
nothing at all: Kernel and the Gateway don't require one.
- **Kernel and the Gateway container**: never yours to modify. They're the invariant core every app runs unchanged
(see [Architecture](/concepts/architecture)).
## What an app is not
- Not a fork of Kernel or the Gateway: you never edit their source to build an app.
- Not a monolith: a Worker plugin, a Gateway plugin, and a UI are independently swappable pieces, not one codebase.
- Not a replacement for the Kernel: an app always sits on top of it, never instead of it.
## Next steps
- Understand [the integration model](/concepts/the-integration-model): what a Worker plugin and a Gateway plugin each get to do
- Understand [architecture](/concepts/architecture): how the pieces of an app talk to each other
- [Build an app](/tutorials/build-a-dashboard) from an empty directory
## Next steps
- Understand [the integration model](/concepts/the-integration-model)
- Understand [architecture](/concepts/architecture)
- [Build an app](/tutorials/build-a-dashboard)
# Guides (/guides)
}
title="Run an MDK site"
href="/guides/deployment"
description="Choose a deployment topology and run a production or single-process MDK site"
/>
}
title="Gateway"
href="/guides/gateway"
description="Run, configure, and extend the MDK Gateway"
/>
}
title="Miner Workers"
href="/guides/miners"
description="Connect Antminer, Avalon, or Whatsminer hardware to an MDK stack"
/>
}
title="UI"
href="/guides/ui"
description="Compose reporting layouts and wire the React UI Devkit into your app"
/>
# 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
covers the concepts; these guides cover running it.
If Gateway, Kernel, or plugin are unfamiliar, read [terminology](/reference/glossary) first.
## Choose a guide
| Goal | Guide |
| --- | --- |
| Run the agent as a standalone CLI, for local development or evaluation | `backend/core/agent/README.md` |
| Deploy the agent behind the Gateway as a chat API for an operator UI | [Deploy the agent behind the Gateway](/guides/agent/gateway-deployment) |
| Expose a plugin's own routes to the agent as tools | [Expose data to the agent](/guides/agent/expose-data) |
## Next steps
- Understand the agent as a stack component
- Understand the Gateway as a development surface
# Expose data to the agent (/guides/agent/expose-data)
## Overview
The operator agent calls fleet data and actions as MCP tools. A Gateway plugin's routes become those tools automatically when
mounted with `autoGenerateMcp: true` — no separate MCP manifest to author and keep in sync with the plugin's own routes.
## Prerequisites
- The [Gateway is running](/guides/gateway/run)
- A [Gateway plugin](/guides/gateway/plugins) already mounted via `extraPluginDirs`
### Auto-generate tools from a plugin
Pass `{ dir, autoGenerateMcp: true }` instead of a plain path to also expose a plugin's HTTP routes as MCP tools:
```js
await startGateway({
kernel,
port: 3000,
extraPluginDirs: [
{ dir: path.join(__dirname, 'plugins/custom-metrics'), autoGenerateMcp: true }
],
mcp: { port: 3100 }
})
```
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, and the same route handler and live `mdkClient`
connection serve both interfaces. The Gateway starts one in-process MCP server (Streamable HTTP, default port
`opts.port + 100`) covering every auto-generated tool across all mounted plugins.
### Write tools by hand instead
A plugin that needs a different tool granularity, richer descriptions, or direct `mdkClient` calls can still author an
`mcp-plugin.json` by hand and run it with a standalone [MCP server](https://github.com/tetherto/mdk/blob/main/examples/full-site/docs/mcp-server.md).
## Next steps
- [Build the plugin whose routes you want to expose](/guides/gateway/plugins)
- [Enable the operator agent](/guides/agent/gateway-deployment) to call the tools this produces
- Understand the agent as a stack component
# Deploy the agent behind the Gateway (/guides/agent/gateway-deployment)
## Overview
`@tetherto/mdk-plugin-agent` mounts `@tetherto/mdk-agent` behind the Gateway as a chat API. It is not a
separate product to adopt: enabling the plugin brings session, message, and approval routes with it, and every write the agent
proposes pauses for an operator's decision. The agent itself still reaches fleet data the way any AI agent does,
over the standalone MCP server; this plugin only gives a human operator a chat surface to talk to it through. This is one of
two ways to run the agent: for the standalone CLI path, or to compare the two, start from [the agent guide chooser](/guides/agent).
## Prerequisites
- The [Gateway is running](/guides/gateway/run)
- The plugin is selected during `mdk onboard`, or mounted directly through `extraPluginDirs`
- `config.agent` is populated with a model provider, an MCP server url, and an approval timeout
- An MCP tool server is reachable, so the agent has fleet tools to call
### Mount the plugin
#### 1.1 Select it during onboarding
`mdk onboard` lists `mdk-plugin-agent` in its Gateway plugin catalog. Its entry carries a real `repoPath`
(`backend/plugins/agent`), not a stub, so selecting it installs a working plugin rather than a placeholder.
#### 1.2 Or mount it directly
Pass its directory through `extraPluginDirs`, with the model provider, the MCP url, and the approval timeout under `agent`.
Once published, that directory is `node_modules/@tetherto/mdk-plugin-agent`; in this monorepo checkout it is
`backend/plugins/agent`:
```js
const path = require('path')
const { startGateway } = require('@tetherto/mdk/backend/core/mdk')
await startGateway({
kernel,
port: 3000,
extraPluginDirs: [
{
dir: path.join(__dirname, ''), // backend/plugins/agent in this checkout
config: {
agent: {
// 'qvac' is the only implemented provider kind, required even for a non-QVAC endpoint;
// 'external' mode just wraps any OpenAI-compatible chat-completions endpoint at baseURL
provider: { kind: 'qvac', mode: 'external', model: 'qwen3-4b', baseURL: 'http://127.0.0.1:11500/v1' },
mcp: { url: 'http://127.0.0.1:3008/mcp' },
approvalTimeoutMs: 120000
}
}
}
]
})
```
No auth plugin means every request binds to a single `local` operator, so a perimeter-trusted deployment gets the full chat and
approval flow with no identity setup at all. A missing `config.agent` block answers `503 ERR_AGENT_UNAVAILABLE` instead of
failing to load.
### Create a session and send a message
Use the port `startGateway({ port })` was given: `3000` in the snippet above, `3007` if this is mounted alongside the
full-site example.
```bash
curl -X POST http://localhost:/agent/sessions
# {"sessionId":"..."}
curl -N -X POST http://localhost:/agent/sessions//messages \
-H 'Content-Type: application/json' \
-d '{"text":"how many miners are on the site?"}'
```
The response streams as `text/event-stream`. A read-only question ends in `tool_call`, `tool_result`, `token`, and `done`
events, each stamped with the turn's `turnId` and a monotonic `seq`.
### Approve a write
A write action pauses the turn instead of running it:
```text
event: pending_approval
data: {"type":"pending_approval","name":"act_device","args":{"ref":"whatsminer-0","action":"reboot"},"approvalId":"..."}
```
Decide it from the paused stream's `approvalId`:
```bash
curl -X POST http://localhost:/agent/sessions//approvals/ \
-H 'Content-Type: application/json' \
-d '{"approved":true}'
```
Approving resumes the same stream: the tool runs for real, and the turn continues to its `token` and `done` events. Rejecting,
or letting the approval window expire, resolves to false, and the write never runs.
## Next steps
- Read the agent plugin's route reference: session, message, and approval routes, plus the manifest's `setup` fields
- Understand the underlying agent: the model, its fleet tools, and the eval battery that scores it
- [Submit and approve write actions](/guides/gateway/write-actions) from a React app, for the UI-driven shape of this same approval gate
# Install the CLI (/guides/cli/install)
`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
- [git](https://git-scm.com/)
`@tetherto/mdk-cli` isn't published to npm yet — install from a tagged source checkout.
## Install
```bash
git clone --branch v0.7.0 https://github.com/tetherto/mdk.git && cd mdk/packages
npm install
cd cli && npm run build && npm link
```
## Verify
```bash
mdk --version
# 0.7.0
```
## Command groups
| Group | Commands |
| ----------------- | ------------------------------------------------------------ |
| Onboarding | `mdk onboard` |
| Scaffold | `mdk create worker\|plugin\|dashboard` |
| Run & manage | `mdk run [target]`, `mdk status` |
| Agent enablement | `mdk skill add` |
| Meta | `mdk version` |
See the full flag reference in the CLI's command surface.
## Update
```bash
git pull && npm install && npm run build
```
## Uninstall
```bash
npm rm -g @tetherto/mdk-cli
```
## Next
[Try the demo](/tutorials/run-a-site) to see a stack running end to end.
# Run a container Worker (/guides/containers)
## Overview
MDK drives each container system through its own Worker. These guides are task-focused and independent, you only need the one for the hardware you
operate.
If Kernel, Worker, manager, or thing are unfamiliar, read [terminology](/reference/glossary) first.
## Pick your hardware
The authoritative model list for every Worker is the generated [supported-hardware catalogue](/reference/supported-hardware#containers). 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 >=24 (LTS)
- npm >=11
- Dependencies installed (`npm run setup` from the repo root)
- Commands are run from the repo root
- Outbound network access for Kernel discovery
For the mock or development path:
- No physical container is required
- The runnable example for your model starts the bundled mock and registers it
HRPC relies on HyperDHT for peer connectivity. Use the [network requirements and checks](/guides/miners/troubleshooting)
if an example stalls before printing the Kernel key.
For the deployment path:
- A Node.js service or script in your deployment that runs the MDK Worker and registers devices
- A supported container system reachable from the machine or container running the Worker
- The Worker's README for the exact `registerThing` options
## Next steps
- Browse [supported hardware](/reference/supported-hardware)
- New to the moving parts? Read [terminology](/reference/glossary) (Kernel, Worker, manager, thing, mock)
- If an example does not start or a mock port is busy, use [miner troubleshooting](/guides/miners/troubleshooting), the same HRPC and DHT checks apply
- Drive the registered device from a dashboard: [run a mining site end to end](/tutorials/run-a-site)
# Run an Antspace Worker (/guides/containers/run-antspace-worker)
## Overview
This page details how to run the Bitmain Antspace container Worker. Select the development (mock) or real-container path.
## Prerequisites
- Review the [common deployment prerequisites](/guides/containers#prerequisites) before you start
Deployment-specific requirements:
- A Node.js service or script in your deployment that runs the MDK Worker and registers containers
- A supported Antspace container reachable from the machine or container running the Worker over its REST HTTP API, typically port `8000`
### Development
Run against a mock
To support development, this repo ships a runnable example that starts the bundled mock, boots the Worker against it, starts a Kernel, and registers the container:
```bash
node examples/backend/containers/antspace/index.js
```
It prints the Kernel HRPC key and the registered device ID, then stays running until Ctrl+C. For the mock's model and port options, see [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/backend/core/mdk')
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',
seedDevices: [{
info: { serialNum: 'HK3-A', container: 'container-A', location: 'site-texas-01.container' },
opts: { address: '192.168.1.100', port: 18001 }
}]
})
await kernel.registerWorker(worker.runtime.getPublicKey())
```
Make sure each container's address is reachable from the machine or container running the Worker before registering. Commands affect live cooling for racks of miners, prioritize thermal safety.
`seedDevices` only seeds a fresh, empty `storeDir`, once persisted, the device set survives restarts on its own. To add a container to an already-running fleet, send the `registerThing` command to the live Worker instead:
```js
const { createMdkClient } = require('@tetherto/mdk/backend/core/client')
const client = createMdkClient({ hrpc: { key: kernel.getPublicKey() } })
await client.connect()
await client.sendWorkerCommand('antspace-rack-1', null, 'registerThing', {
id: 'HK3-B',
info: { serialNum: 'HK3-B', container: 'container-A' },
opts: { address: '192.168.1.101', port: 18001 }
})
```
`registerThing` persists the container config immediately, but the running Worker does not pick it up until it is stopped and restarted (`await worker.stop()`, then call `startAntspaceWorker` again with the same `storeDir` and no `seedDevices`), there is no hot-add.
For the full `seedDevices` and `registerThing` option reference, the telemetry and command tables, and the shared install pattern, see [the Antspace README](https://github.com/tetherto/mdk/blob/main/backend/workers/containers/antspace/README.md) and [install pattern](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/install-pattern.md).
## Troubleshooting
The development example on this page is `examples/backend/containers/antspace/index.js`. A working run 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 the mock port 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 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
To support development, this repo ships a runnable example that starts the Worker (embedding its MQTT broker), points a mock D40 container at that broker as an MQTT client, starts a Kernel, and registers the container:
```bash
node examples/backend/containers/bitdeer/index.js
```
It prints the Kernel HRPC key and the registered device ID, then stays running until Ctrl+C. For the mock's model and container ID options, see [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/backend/core/mdk')
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,
seedDevices: [{
info: { serialNum: 'D40-M56-001', container: 'container-A' },
opts: { containerId: 'D40-M56-001' }
}]
})
await kernel.registerWorker(worker.runtime.getPublicKey())
```
Make sure the container is configured to publish into this Worker's broker port before registering. Commands act on
physical cooling and power hardware, prioritize thermal safety.
`seedDevices` only seeds a fresh, empty `storeDir`, once persisted, the device set survives restarts on its own.
To add a container to an already-running fleet, send the `registerThing` command to the live Worker instead:
```js
const { createMdkClient } = require('@tetherto/mdk/backend/core/client')
const client = createMdkClient({ hrpc: { key: kernel.getPublicKey() } })
await client.connect()
await client.sendWorkerCommand('bitdeer-rack-1', null, 'registerThing', {
id: 'D40-M56-002',
info: { serialNum: 'D40-M56-002', container: 'container-A' },
opts: { containerId: 'D40-M56-002' }
})
```
`registerThing` persists the container config immediately, but the running Worker does not pick it up until it is stopped
and restarted (`await worker.stop()`, then call `startBitdeerWorker` again with the same `storeDir` and no `seedDevices`),
there is no hot-add.
For the full `seedDevices` and `registerThing` option reference, the telemetry and command tables, and the shared install pattern, see [the Bitdeer README](https://github.com/tetherto/mdk/blob/main/backend/workers/containers/bitdeer/README.md) and [install pattern](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/install-pattern.md).
## Troubleshooting
The development example on this page is `examples/backend/containers/bitdeer/index.js`. A working run 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 the broker port 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.
## Choose a guide
- [Single-process](/guides/deployment/run-single-process-site) — run Kernel, Gateway, and Workers in one Node.js process
- [Supervised services](/guides/deployment/run-all-workers-site) — run a multi-Worker site as separate, PM2-supervised processes, from one machine up to a cross-host deployment
## Next steps
- Understand the trade-offs before you choose your deployment topology
- Browse the [functions](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md) that wire together the Kernel, device Workers, and the Gateway HTTP
# Run the supported Worker fleet with mock devices (/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
Gateway plugin 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 Gateway plugin 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` 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. For a worked cross-host walkthrough today, see
[`examples/full-site`'s `cli.js --discovery dht`](https://github.com/tetherto/mdk/blob/main/examples/full-site/README.md#how-out-of-process-workers-find-the-kernel).
## 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)
- Run [a single-process site](/guides/deployment/run-single-process-site) for the simpler single-process topology
- Register a single miner before building a site config — [Run a miner Worker](/guides/miners)
- Extend the Gateway HTTP API with [custom plugins](/guides/gateway/plugins)
- Browse the [functions](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md) that wire together the Kernel, device Workers, and the Gateway HTTP
- Build your own [Worker from scratch](/guides/workers/build-a-worker)
# Run a single-process site (/guides/deployment/run-single-process-site)
This thin page directs you to the correct location for the prerequisites, config fields, run command, smoke test, and troubleshooting.
## Overview
Use the **single-process** site example when you want Kernel, the Gateway, and Worker to share one Node.js process.
This page is the task guide for the single-process topology.
The [deployment topologies](/guides/deployment) concept explains when to choose single-process instead of a supervised, multi-process deployment.
## Use this topology when
- You are developing locally, running demos, or writing self-contained tests
- You want a minimal-footprint deployment
- You do not need per-service restart isolation
## Run the example
Follow the [single-process site example](https://github.com/tetherto/mdk/tree/main/examples/full-site):
- Start with its [prerequisites](https://github.com/tetherto/mdk/tree/main/examples/full-site#prerequisites)
- Use the example [quick smoke test and full run](https://github.com/tetherto/mdk/tree/main/examples/full-site#quick-smoke-test-recommended-first-run)
## Next steps
- Compare the supported shapes: [Deployment topologies](/guides/deployment)
- Run the supervised topology — [Run a multi-Worker site as supervised services](/guides/deployment/run-all-workers-site)
- Register a single miner before building a site config — [Run a miner Worker](/guides/miners)
# 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 through the standalone [`@tetherto/mdk-mcp`](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) package instead of the Gateway's HTTP surface.
If Gateway, Kernel, or plugin are unfamiliar, read [terminology](/reference/glossary) first. For the full developer model — extension, data access,
auth design — read the Gateway concept page.
## Choose a guide
| Goal | Guide |
| --- | --- |
| Start the Gateway for the first time | [Run the Gateway](/guides/gateway/run) |
| Use built-in plugins or build your own | [Gateway plugins](/guides/gateway/plugins) |
| 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) |
## Next steps
- Understand the Gateway as a development surface
- 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
# Gateway plugins (/guides/gateway/plugins)
## Overview
The Gateway exposes HTTP routes through a declarative plugin system. Each plugin is a directory containing an
[`mdk-plugin.json`](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md#manifest-format) manifest and one or more controller files. MDK ships a set of default plugins that load automatically;
you can mount additional plugins for your own site logic.
A plugin 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 [Gateway is running](/guides/gateway/run)
- A Kernel instance running and reachable, or `kernelKey: false` to start without a Kernel connection (development only)
## Default plugins
MDK ships plugins that load automatically on Gateway startup:
- The `telemetry` plugin serves site metrics (hashrate, consumption, efficiency, temperature, and more)
- The `site-hashrate` plugin serves aggregated site hashrate history
- The `site-monitor` plugin serves site configuration, feature flags, and live per-device hashrate
The [`auth` plugin](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md#the-bundled-auth-plugin) (`@tetherto/mdk-plugin-auth`) ships in the same package but is not among them, and mounting it via
`extraPluginDirs` does not give you working identity endpoints: its controllers still expect a second handler parameter and a populated
`req._info` that the Gateway does not provide. Supply your own identity layer.
The [plugin reference](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md) lists every route each of these plugins serves, with its method, generated from the plugin's
`mdk-plugin.json`. Plugins you mount yourself are documented by their own manifests.
### Mount a plugin
Pass an `extraPluginDirs` array to `startGateway()` to load additional plugins at boot alongside the default plugins:
```js
const { startGateway } = require('@tetherto/mdk/backend/core/mdk')
await startGateway({
kernel,
port: 3000,
extraPluginDirs: [
path.join(__dirname, 'plugins/custom-metrics'),
path.join(__dirname, 'plugins/alerts')
]
})
```
Each entry must be an absolute path to a directory containing an [`mdk-plugin.json`](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md#manifest-format). The plugin loader validates the
manifest and all handler files at startup — missing files or invalid manifests throw immediately before the server comes up.
[Exposing a plugin's routes to the operator agent](/guides/agent/expose-data) turns them into MCP tools with no separate manifest, using this
same `extraPluginDirs` entry plus one flag.
### Build a plugin
A plugin is a directory with two things: a manifest and controllers.
#### 1.1 Create the manifest
[`mdk-plugin.json`](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md#manifest-format) declares the plugin identity (`name`, `version`) and a `routes` array. Each route needs an `id`, a `handler` path, and an `http`
block with a `method` and `path`. Rather than copy a synthetic example, start from a real manifest and trim it:
- [`examples/backend/mdk-plugin-e2e/gateway-plugin/mdk-plugin.json`](https://github.com/tetherto/mdk/blob/main/examples/backend/mdk-plugin-e2e/gateway-plugin/mdk-plugin.json): one route, fully annotated with a response
schema, `constraints`, `errors`, and `safety`. The easiest starting point, and seeing a plugin serve your data
runs it end to end
- [`examples/mvp-site/backend/gateway-plugins/site/mdk-plugin.json`](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/backend/gateway-plugins/site/mdk-plugin.json): four routes including `GET`s with query
parameters, and `POST`s with a `requestBody` and path parameters
- [`backend/core/plugins/telemetry/mdk-plugin.json`](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/telemetry/mdk-plugin.json): auth, caching, query parameters, and named-export handlers
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](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md) explains what each field means and what the loader requires.
#### 1.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 plugin's
context config, in a [`lib/client.js`](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/telemetry/lib/client.js) every controller in the plugin requires.
Every controller exports an `async function (req)`:
```js
// controllers/live.js — read live telemetry
const mdkClient = require('../lib/client')
module.exports = async function live (req) {
const deviceId = req.query.deviceId
const telemetry = await mdkClient.pullTelemetry(deviceId, 'metrics')
return { deviceId, ...telemetry }
}
```
```js
// controllers/command.js — dispatch a command
const mdkClient = require('../lib/client')
module.exports = async function command (req) {
const deviceId = req.params.deviceId
const { mode } = req.body
const result = await mdkClient.sendCommand(deviceId, 'setPowerMode', { mode })
return {
deviceId,
commandId: result.commandId,
status: result.status
}
}
```
### 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 plugin's context module
`require('@tetherto/mdk-gateway/plugin')` resolves, inside a loaded plugin, to that plugin's own frozen context. The
controller reference shows a controller building its own client from it:
| Field | Type | Contains |
| --- | --- | --- |
| `config` | `object` | The Gateway's runtime config, with `kernelKey`/`kernelBootstrap` folded in, and this plugin's own per-plugin config layered over the top key-by-key |
### Supplying per-plugin config
That per-plugin config isn't declared in `mdk-plugin.json` — it comes from the stack spec (`spec.gateway.plugins[].config`), passed as a `config` key alongside `dir` in the `extraPluginDirs` entry:
```js
extraPluginDirs: [
{ dir: path.join(__dirname, 'plugins/custom-metrics'), config: { apiKey: process.env.METRICS_API_KEY } }
]
```
Build a [`lib/client.js`](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/telemetry/lib/client.js) from it once per plugin 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:
Migrate from the `services` parameter (pre-0.7)
A controller used to take `(req, services)`, a `services` object the Gateway passed to every plugin.
| Before | After |
| --- | --- |
| `module.exports = (req, services) => …` | `module.exports = (req) => …` |
| `services.conf` | `config` from `require('@tetherto/mdk-gateway/plugin')` |
| `services.mdkClient` | The plugin builds its own from `config.kernelKey` / `config.kernelBootstrap` |
| `services.dataProxy` | Removed with the data proxy |
| `services.authLib` | Removed in 0.6.0 |
Drop the second handler parameter, read `config` from the context module, and build your own MDK client for Kernel
access — the bundled `site-monitor`, `site-hashrate`, and `telemetry` plugins each ship a `lib/client.js` showing
the pattern.
`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`
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)
```
[`site-monitor/controllers/hashrate.js`](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/site-monitor/controllers/hashrate.js) is the shipping example this pattern is copied from.
There is no separate Gateway-side store for historical or aggregated data, either. Fan `pullWorkerTelemetry`
out across every registered Worker and read the series from the Worker's own persisted tail-log:
```js
const { workers } = await mdkClient.listWorkers()
const results = await Promise.allSettled(
workers.map((w) => mdkClient.pullWorkerTelemetry(w.workerId, { type: 'logs', key: 'stat-1D', tag: 't-miner', start, end }))
)
```
The [default telemetry controllers](https://github.com/tetherto/mdk/tree/main/backend/core/plugins/telemetry/controllers) and [`telemetry/lib/site-data.js`](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/telemetry/lib/site-data.js) show a worked,
production version of this fan-out (aliasing, error tolerance per Worker, and the aggregation shapes each route returns).
### Send a command
`sendCommand` dispatches via the Kernel to the Worker that owns the device — the command
must be declared in the Worker's `mdk-contract.json`. `controllers/command.js` above already shows the pattern; the
client's own reference documents the full return shape (`commandId`, `status`, `result`, `error`).
### Caching
Add a `"cache"` array of dot-path strings to a route to enable request-level caching, bypassed with
`?overwriteCache=true`. [The manifest reference](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md#manifest-format) 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](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md#manifest-format) covers the mechanism and the
handler's error behavior. `backend/plugins/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
The Gateway applies no authentication of its own, as its authentication design describes. Every route a plugin declares
is served to any caller, so a route that needs protecting carries that logic in its own controller. Identity is yours to supply: the manifest
`"auth"` and `"permissions"` fields have no reader and change nothing. The [bundled `auth` plugin](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md#the-bundled-auth-plugin) is not a substitute — the
Gateway neither registers it nor gives its controllers what they still expect.
Validate the token with your own identity layer and check it in the handler:
```js
const { validateToken } = require('../lib/my-identity-layer')
module.exports = async function protectedRoute (req) {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) throw new Error('ERR_UNAUTHORIZED')
const { permissions } = validateToken(token)
if (!permissions.includes('miner:w')) throw new Error('ERR_FORBIDDEN')
// Your route logic
}
```
A controller cannot choose its status code. It receives `(req)` and never the Fastify reply, so a returned value goes out as `200` and a
thrown `ERR_`-prefixed error becomes `400 Bad Request` carrying that message. `ERR_UNAUTHORIZED` reaches the client as `400`, not `401`. A route that
needs true status control belongs in [raw Fastify routes](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md#raw-fastify-routes) instead.
### Manifest validation errors
The plugin loader validates every manifest and handler at startup and throws if anything is wrong — see
the loader's error codes for the full list.
## Next steps
- Try the [live site backend example](/guides/deployment/run-all-workers-site) for a complete worked plugin 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-plugin + controller pattern
- Understand [how Workers declare their data](/guides/workers/build-a-worker) via `mdk-contract.json` — what `mdkClient` reads and `sendCommand` dispatches
- See the full [manifest and controller reference](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md)
- Review the [Gateway API and config](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md)
# 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.
## Prerequisites
- Node.js >=24 (LTS)
- npm >=11
- Commands are run from the repository root
- A Kernel instance running and reachable, or `kernelKey: false` to start without a Kernel connection (development only)
### Programmatic path
Most teams embed `startGateway()` in their own Node.js application rather than running the Gateway as a separate process.
This is the standard production path.
```js
const { getKernel, startGateway } = require('@tetherto/mdk/backend/core/mdk')
const kernel = await getKernel()
const server = await startGateway({ kernel, port: 3000 })
// HTTP server is up at http://localhost:3000
```
The Gateway ships no built-in authentication, so every route it serves is unauthenticated. Supply your own identity layer and call it from the
controllers that need protecting, as [auth and permissions](/guides/gateway/plugins#auth-and-permissions) describes. The [`@tetherto/mdk-plugin-auth`](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md#the-bundled-auth-plugin) plugin bundled
with MDK is not a substitute: the Gateway neither registers it nor provides what its controllers expect.
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/backend/core/mdk')
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/backend/core/mdk')
const server = await startGateway({
kernelKey: '',
port: 3000
})
```
Pre v1.0, Kernel's allowlist `auth.whitelist` defaults to empty and admits any HRPC caller. For production deployments, add the Gateway's
DHT public key to Kernel's allowlist — see the Gateway concept page 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/plugins)
- [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
- Choose a [deployment shape](/guides/deployment)
# Tear down MDK services (/guides/gateway/teardown)
## Overview
MDK registers graceful shutdown handlers automatically when you start services with `getKernel()`, a Worker boot function, or
`startGateway()`. For most deployments, `SIGINT` (Ctrl+C) triggers a clean teardown with no extra code. This guide covers the three
situations where you need to think about teardown explicitly:
- [Automatic 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
- MDK [installed and a working boot sequence](/guides/gateway/run)
### Automatic teardown with `getKernel()`
`getKernel()` registers `SIGINT`/`SIGTERM` handlers internally. 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/backend/core/mdk')
const { startWhatsminerWorker } = require('@tetherto/mdk-worker-whatsminer')
const kernel = await getKernel()
const { runtime, stop } = await startWhatsminerWorker({ workerId: 'whatsminer-rack-1', model: 'm56s', storeDir: './data/whatsminer' })
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 for the same-process
lifecycle rules.
### Explicit teardown in tests or scripted runs
Short-lived processes — integration tests, one-shot scripts — never receive `SIGINT`. 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/backend/core/mdk')
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/backend/core/mdk')
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()` 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` 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 demonstrates how to submit approval-gated write actions from a React app, review the server-side voting queue, and approve,
reject, or cancel pending actions through the Gateway.
## Prerequisites
- The [Gateway is running](/guides/gateway/run) with an [actions plugin mounted](#create-an-actions-plugin)
- Your actions plugin controllers validate tokens and check permissions themselves, since [an unprotected route is reachable by anyone](/guides/gateway/plugins#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)
- The feature stages write actions in [`actionsStore`](https://github.com/tetherto/mdk/blob/main/ui/packages/react-adapter/README.md#write-action-hooks) from `@tetherto/mdk-ui-foundation` or provides
actions through an existing feature such as [Pool Manager](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/blueprints/pool-manager.md)
### Submit staged actions
#### 1.1 Submit a single action
Use `useSubmitSingleAction()` when the UI lets an operator submit one staged action by id.
```tsx
function SubmitActionButton({ actionId }: { actionId: number }) {
const submit = useSubmitSingleAction();
return (
);
}
```
#### 1.2 Submit all staged actions
Use `useSubmitPendingActions()` when the UI has a review tray or bulk-submit control that should send the whole local staging queue.
```tsx
function SubmitActionsButton() {
const submitPending = useSubmitPendingActions();
return (
);
}
```
### Review the server-side queue
After submission, actions move from the local staging queue into the Gateway's voting surface (typically exposed by a plugin at routes like `/auth/actions*`).
#### 2.1 Review with `usePendingActions()`
Use `usePendingActions()` for a pending-action review table. Pass `refetchInterval` to override the default poll cadence (see [hook reference](https://github.com/tetherto/mdk/blob/main/ui/packages/react-adapter/README.md#write-action-hooks)).
```tsx
function PendingActionsList() {
const { data: pending = [], isLoading } = usePendingActions({
refetchInterval: 5000,
});
if (isLoading) return
Loading pending actions...
;
return (
{pending.map((action) => (
{action.id}
))}
);
}
```
#### 2.2 Review with `useLiveActions()`
Use `useLiveActions()` when the UI needs to separate the current user's actions from others and gate approve/reject controls on `canApprove`.
For polling cadence and role logic, see the [hook reference](https://github.com/tetherto/mdk/blob/main/ui/packages/react-adapter/README.md#write-action-hooks).
### Approve or reject an action
Use `useVoteOnAction()` to cast an approval or rejection. The hook calls the Gateway's voting endpoint (for example, `PUT /auth/actions/voting/:id/vote` if using that plugin pattern) and invalidates the relevant action caches. Disable direct vote buttons when `canVote` is false. Review-tray UIs that approve other users' actions should combine this mutation with `useLiveActions().canApprove`.
```tsx
function VoteButtons({ actionId }: { actionId: string }) {
const vote = useVoteOnAction();
return (
<>
>
);
}
```
### Cancel pending actions
Use `useCancelAction()` when the current operator should withdraw one or more pending actions before the vote thresholds are met. The hook calls the Gateway's cancel endpoint (for example, `DELETE /auth/actions/voting/cancel` if using that plugin pattern).
```tsx
function CancelActionButton({ actionId }: { actionId: string }) {
const cancel = useCancelAction();
return (
);
}
```
### Verify the result
Approved actions become command requests after the configured vote thresholds are met. Watch the feature state that initiated the
action, or poll the action list with `usePendingActions()` / `useLiveActions()` until the item leaves the voting queue.
For Pool Manager screens, use the existing [actions sidebar USAGE](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/src/domain/components/pool-manager/actions-sidebar/USAGE.md) and
[Pool Manager blueprint](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/blueprints/pool-manager.md) as the integration examples.
## Create an actions plugin
To enable approval-gated writes, create a plugin that exposes HTTP routes for the write-action workflow. Each route should call the corresponding method on the plugin's own `mdkClient` (built from `require('@tetherto/mdk-gateway/plugin')`, [as any Gateway plugin does](/guides/gateway/plugins)).
The paths shown below (`/auth/actions*`) are illustrative examples. You may use any path structure that fits your plugin's routing pattern.
### Required routes
| Method | Example Path | mdkClient Method | Purpose |
|--------|--------------|------------------|---------|
| `GET` | `/auth/actions` | `queryActions()` | Query actions by lifecycle state (voting/ready/executing/done) |
| `POST` | `/auth/actions/voting` | `pushAction()` | Submit a single action for approval |
| `POST` | `/auth/actions/voting/batch` | `pushActionsBatch()` | Submit multiple actions for approval |
| `PUT` | `/auth/actions/voting/:id/vote` | `voteAction()` | Cast approval/rejection vote on an action |
| `DELETE` | `/auth/actions/voting/cancel` | `cancelActionsBatch()` | Cancel pending actions by IDs |
### Plugin structure
```text
backend/plugins/actions/
├── mdk-plugin.json
└── controllers/
├── query.js
├── push.js
├── push-batch.js
├── vote.js
└── cancel.js
```
### Example controller (push.js)
```javascript
'use strict'
const { validateToken } = require('../lib/my-identity-layer')
const mdkClient = require('../lib/client')
module.exports = async function pushAction (req) {
// Identity comes from your own layer: nothing populates req._info
const { email: voter, permissions: authPerms } = validateToken(req.headers.authorization)
return await mdkClient.pushAction({
query: req.body.query, // Device query/selector
action: req.body.action, // Action name from worker contract
params: req.body.params, // Action parameters
voter, // Current user identifier
authPerms // User's permissions (e.g., ['miner:w'])
})
}
```
[`lib/client.js`](/guides/gateway/plugins) builds the client once for the whole plugin, per the pattern in the plugin authoring guide.
### Manifest example (mdk-plugin.json)
```json
{
"name": "@yourorg/mdk-plugin-actions",
"version": "1.0.0",
"description": "Approval-gated write action APIs",
"routes": [
{
"id": "actions.push",
"handler": "./controllers/push.js",
"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"
}
]
}
```
This manifest declares no protection, and none is applied on its behalf. The route accepts any caller until its controller validates the request,
which matters more here than on a read route because it stages fleet-changing writes. [Auth and permissions](/guides/gateway/plugins#auth-and-permissions) covers the patterns.
### Mount the plugin
```javascript
const { startGateway } = require('@tetherto/mdk/backend/core/mdk')
const path = require('path')
await startGateway({
kernel,
extraPluginDirs: [
path.join(__dirname, 'backend/plugins/actions')
]
})
```
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 — including how approved actions become normal command requests
- Protect your routes with [controller-level auth and permission checks](/guides/gateway/plugins#auth-and-permissions)
- Build routes with the [Gateway plugin format](/guides/gateway/plugins), including caching and manifest fields
- Review hook exports in [`@tetherto/mdk-react-adapter`](https://github.com/tetherto/mdk/blob/main/ui/packages/react-adapter/README.md)
- Run integration coverage: [`backend/core/kernel/tests/integration/actions.test.js`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/tests/integration/actions.test.js)
# Run a miner Worker (/guides/miners)
## Overview
MDK drives each miner brand through its own Worker. These guides are task-focused and **independent** — you only need the one for the hardware you operate.
If Kernel, Worker, manager, or thing are unfamiliar, read [terminology](/reference/glossary) first.
## Pick your hardware
The authoritative model list for every Worker is the generated [supported-hardware catalogue](/reference/supported-hardware#miners). 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 >=24 (LTS)
- npm >=11
- Dependencies installed (`npm run setup` from the repo root)
- Commands are run from the repo root
- Outbound network access for Kernel discovery
For the mock/development path:
- No physical miner is required
- The runnable example for your model starts the bundled mock device and registers it
HRPC relies on HyperDHT for peer connectivity. Use the [network requirements and checks](/guides/miners/troubleshooting)
if an example stalls before printing the Kernel key.
For the deployment path:
- A Node.js service or script in your deployment that runs the MDK Worker and registers devices
- A supported miner reachable from the machine or container running the Worker
- Access to the miner's native API and credentials, if that API requires them
- The Worker's `USAGE.md` for the exact `registerThing` options
## Next steps
- Browse [supported hardware](/reference/supported-hardware)
- New to the moving parts? Read [terminology](/reference/glossary) (Kernel, Worker, manager, thing, mock)
- If an example does not start or a mock port is busy, use [troubleshooting](/guides/miners/troubleshooting)
- Drive the registered device from a dashboard: [run a mining site end to end](/tutorials/run-a-site)
# Run an Antminer Worker (/guides/miners/run-antminer-worker)
## Overview
This page details how to run the Bitmain Antminer Worker. Select the development (mock) or real-device path.
## Prerequisites
Review the [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 Antminer device reachable from the machine or container running the Worker
- The miner API reachable over HTTP, typically port `80`
- Digest-auth credentials for the miner. Antminer devices commonly default to username `root` and password `root`, but use your site's configured credentials
### Development
Run against a mock
To support development, this repo ships a config-driven runnable example that boots a mock device per configured Worker, starts a Kernel and Gateway, and starts each Worker (`startAntminerWorker`) against its mock:
```bash
node examples/backend/miners/antminer/index.js
```
It falls back to the committed example config (`config/mdk.config.json.example`) when no local `config/mdk.config.json` is present, so it runs clone-and-run with zero setup. It prints the Kernel HRPC key and one line per registered device, then stays running until Ctrl+C. For details on the boot options and mock, see [USAGE.md](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/antminer/USAGE.md).
### Connect a miner
#### 2.1 Pick your model
Use the Antminer Worker's [USAGE.md](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/antminer/USAGE.md) to confirm the `model` value and mock `type` for your device. This guide uses `s21`; replace it with the value for your miner.
#### 2.2 Register your miner
Antminer devices use an HTTP API with digest authentication. 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 Antminer device; replace the example IP address and credentials with your miner's values:
```js
const { getKernel } = require('@tetherto/mdk/backend/core/mdk')
const { startAntminerWorker } = require('@tetherto/mdk-worker-antminer')
const kernel = await getKernel()
const worker = await startAntminerWorker({
workerId: 'antminer-rack-1',
model: 's21',
storeDir: './store/antminer-rack-1',
seedDevices: [{
info: { container: 'site-1', serialNum: 'AM-001' },
opts: { address: '192.168.1.20', port: 80, username: 'root', password: 'root' }
}]
})
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.
`seedDevices` only seeds a fresh, empty `storeDir` — once persisted, the device set survives restarts on its own. To add a device to an already-running fleet, send the `registerThing` command to the live Worker instead:
```js
const { createMdkClient } = require('@tetherto/mdk/backend/core/client')
const client = createMdkClient({ hrpc: { key: kernel.getPublicKey() } })
await client.connect()
await client.sendWorkerCommand('antminer-rack-1', null, 'registerThing', {
id: 'AM-002',
info: { container: 'site-1', serialNum: 'AM-002' },
opts: { address: '192.168.1.21', port: 80, username: 'root', password: 'root' }
})
```
`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 `startAntminerWorker` 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/antminer
./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/antminer/USAGE.md) and the shared [install pattern](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/install-pattern.md).
## Troubleshooting
The development example on this page is `examples/backend/miners/antminer/index.js`. A working run prints the Kernel HRPC key and one line per registered device, then stays running until Ctrl+C.
If it does not print those values, or if a mock port is 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/antminer/plugin/mdk-contract.json)
# 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`
- No API username or password. The Avalon CGMiner API is unauthenticated
### Development
Run against a mock
To support development, this repo ships a runnable example that boots a mock A1346, starts a Kernel and Gateway, and starts the Worker (`startAvalonWorker`) against it:
```bash
node examples/backend/miners/avalon/index.js
```
It prints the Kernel HRPC key and the registered device ID, then stays running until Ctrl+C. For details on the boot options and mock, see [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, which is unauthenticated (no username or 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 with your miner's value:
```js
const { getKernel } = require('@tetherto/mdk/backend/core/mdk')
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',
seedDevices: [{
info: { container: 'site-1', serialNum: 'AV-001' },
opts: { address: '192.168.1.30', port: 4028 }
}]
})
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.
`seedDevices` only seeds a fresh, empty `storeDir` — once persisted, the device set survives restarts on its own. To add a device to an already-running fleet, send the `registerThing` command to the live Worker instead:
```js
const { createMdkClient } = require('@tetherto/mdk/backend/core/client')
const client = createMdkClient({ hrpc: { key: kernel.getPublicKey() } })
await client.connect()
await client.sendWorkerCommand('avalon-rack-1', null, 'registerThing', {
id: 'AV-002',
info: { container: 'site-1', serialNum: 'AV-002' },
opts: { address: '192.168.1.31', port: 4028 }
})
```
`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
The development example on this page is `examples/backend/miners/avalon/index.js`. A working run prints `Kernel HRPC key:` and `Device:`, then stays running until Ctrl+C.
If the example does not print both values, or if its mock port is 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
This page details how to run the MicroBT Whatsminer Worker. Select the development (mock) or real-device path.
## Prerequisites
Review the [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 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
Worker auto-detects the version from the port, or probes both if given a different port
- The Whatsminer API password. The Worker negotiates a session token from it; there is no separate username
### Development
Run against a mock
To support development, this repo ships a runnable example that boots a mock M56S Whatsminer, starts a Kernel, and starts the Worker (`startWhatsminerWorker`) against it:
```bash
node examples/backend/miners/whatsminer/index.js
```
It prints the Kernel HRPC key and the registered device ID, then stays running until Ctrl+C. To try another model, run that model's mock directly (`npm run mock ` from `backend/workers/miners/whatsminer`, or see [USAGE.md](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/whatsminer/USAGE.md)) and adapt the `model` option in your own boot script.
### Connect a miner
#### 2.1 Pick your model
Use the Whatsminer Worker's [USAGE.md](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/whatsminer/USAGE.md) to confirm the `model` value and mock `type` for your device. This guide uses `m56s`; replace it with the value for your miner.
#### 2.2 Register your miner
Whatsminer devices use an encrypted TCP API, port `4028` for API v2 (the default) or `4433` for API v3, with
token-based authentication; the Worker negotiates a session token from the device password (there is no separate
username) and auto-detects the API version from the port. 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 Whatsminer device;
replace the example IP address and password with your miner's values:
```js
const { getKernel } = require('@tetherto/mdk/backend/core/mdk')
const { startWhatsminerWorker } = require('@tetherto/mdk-worker-whatsminer')
const kernel = await getKernel()
const worker = await startWhatsminerWorker({
workerId: 'whatsminer-rack-1',
model: 'm56s',
storeDir: './store/whatsminer-rack-1',
seedDevices: [{
info: { container: 'site-1', 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.
`seedDevices` only seeds a fresh, empty `storeDir` — once persisted, the device set survives restarts on its own. To add a device to an already-running fleet, send the `registerThing` command to the live Worker instead:
```js
const { createMdkClient } = require('@tetherto/mdk/backend/core/client')
const client = createMdkClient({ hrpc: { key: kernel.getPublicKey() } })
await client.connect()
await client.sendWorkerCommand('whatsminer-rack-1', null, 'registerThing', {
id: 'WM-002',
info: { container: 'site-1', serialNum: 'WM-002' },
opts: { address: '192.168.1.11', port: 4028, password: 'admin' }
})
```
`registerThing` persists the device config immediately, but the running Worker does not pick it up until it is stopped and restarted (`await worker.stop()`, then call `startWhatsminerWorker` 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/whatsminer
./setup-config.sh
```
For the full `seedDevices`/`registerThing` option reference, the mock `createServer` options, and the per-model alert blocks, see the Worker's [USAGE.md](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/whatsminer/USAGE.md) and the shared [install pattern](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/install-pattern.md).
## Troubleshooting
The development example on this page uses `examples/backend/miners/whatsminer/index.js`. A working run prints `Kernel HRPC key:` and `Device:`, then stays running until Ctrl+C.
If the example does not print both values, or if its mock port is 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/whatsminer/plugin/mdk-contract.json)
# Troubleshoot miner Workers (/guides/miners/troubleshooting)
## Overview
This page covers the mock/development examples used by the Antminer, Whatsminer, and Avalon 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
Each runnable example starts a mock miner on the port declared in that example file. To find the mock port for your model:
1. Open the Worker's `USAGE.md` and choose the runnable example for your model:
- Antminer: [USAGE.md](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/antminer/USAGE.md#runnable-examples)
- Whatsminer: [USAGE.md](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/whatsminer/USAGE.md#runnable-examples)
- Avalon: [USAGE.md](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/avalon/USAGE.md#runnable-example)
2. Open the matching `examples/run-*.js` file.
3. Look for the `createServer({ port: ... })` call.
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.
## Clean up a mock port
If an example exits with `EADDRINUSE` or says a port is already in use, find the process using that port:
```bash
lsof -nP -iTCP: -sTCP:LISTEN
```
Replace `` with the mock port for your example. The output includes a process ID (`PID`). If the process is an old miner mock or example that you no longer need, stop it:
```bash
kill
```
Run `lsof` again to confirm the port is free before restarting the example.
## Example does not print a Kernel key
Same-process examples register Worker public keys directly and do not use DHT topic discovery. Runtime traffic still uses HRPC,
which relies on HyperDHT to establish encrypted peer connections. The machine therefore needs outbound UDP access to its configured
DHT bootstrap nodes even when Kernel and the Worker share a process or host.
If outbound access or network-interface inspection is blocked, startup may stop responding or fail before printing `Kernel HRPC key:`.
Check:
- The machine has outbound network access.
- Local security tooling, containers, or sandboxes are not blocking UDP/network-interface access.
- You are running the command from the repository root.
- Dependencies have been installed for `backend/core` and [`backend/workers`](/reference/worker).
## File lock or key file errors
The examples call `getKernel()` with default local paths. By default, the topic file is `os.tmpdir()/mdk/.dht-topic` and the kernel key file is `os.tmpdir()/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 temporary directory so each Kernel gets separate local state:
```bash
TMPDIR=/tmp/mdk-antminer-s21 node backend/workers/miners/antminer/examples/run-s21.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
# UI guides (/guides/ui)
}
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"
/>
# 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
# 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": "*"
}
}
```
[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.
> **Coming soon** — npm packages are not yet published. Use the monorepo setup for now.
```bash
npm install \
@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 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. From 0.6.0 the Gateway serves only what its plugins provide, so
the endpoints behind these hooks come from your own plugin. [Adding custom plugins to the Gateway HTTP API](/guides/gateway/plugins) 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()
const { setAddPendingSubmissionAction } = useActions()
// ...
}
```
## 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([])
actionsStore.getState().setAddPendingSubmissionAction({ /* … */ })
```
## 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="Build a custom reporting layout from the same building blocks the prebuilt reporting composites use"
/>
}
title="Compose spare parts inventory flows"
href="/guides/ui/react/compose-spare-parts-inventory-flows"
description="Wire up add, move, bulk-import, and delete flows for spare parts using the dialog components"
/>
# Compose reporting layouts (/guides/ui/react/compose-reporting-layouts)
@tetherto/mdk-react-devkit/foundation
The reporting composites — [`Cost`](/reference/ui/components/dashboards#cost), [`Ebitda`](/reference/ui/components/charts#ebitda), [`EnergyBalance`](/reference/ui/components/charts#energybalance), [`HashBalance`](/reference/ui/components/dashboards#hashbalance), and [`Hashrate`](/reference/ui/components/charts#hashrate) — render fixed, opinionated layouts. When you need a different arrangement (a custom grid, a subset of charts, your own tabs), compose the page yourself from the **same building blocks** those composites are made of.
Every building block receives pre-shaped data as props and does no fetching — wire your own data layer (RTK Query, TanStack, fixtures).
## When to use a building block vs the composite
- Reach for the **composite** (for example ``) for the standard reporting page — fastest path, least wiring.
- Reach for the **building blocks** when you need a custom layout, want only some panels, or are embedding a single chart in your own surface.
## Guides by composite
- [Compose Cost layouts](/guides/ui/react/compose-reporting-layouts/cost)
- [Compose EBITDA layouts](/guides/ui/react/compose-reporting-layouts/ebitda)
- [Compose Energy balance layouts](/guides/ui/react/compose-reporting-layouts/energy-balance)
- [Compose Hash balance layouts](/guides/ui/react/compose-reporting-layouts/hash-balance)
- [Compose Hashrate layouts](/guides/ui/react/compose-reporting-layouts/hashrate)
## 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).
# Compose Cost layouts (/guides/ui/react/compose-reporting-layouts/cost)
@tetherto/mdk-react-devkit/foundation
The [`Cost`](/reference/ui/components/dashboards#cost) composite renders a fixed 2x2 cost-summary layout. To build a custom arrangement, compose it from the building blocks below — each takes pre-shaped data as props and does no fetching.
## Building blocks
| Component | Description |
| --- | --- |
| [`CostContent`](/guides/ui/react/compose-reporting-layouts/cost/#costcontent) | Data-driven 2x2 grid body of the Cost page |
| [`CostMetrics`](/guides/ui/react/compose-reporting-layouts/cost/#costmetrics) | Three \$/MWh cost-summary tiles (all-in, energy, operations) |
| [`AvgAllInCostChart`](/guides/ui/react/compose-reporting-layouts/cost/#avgallincostchart) | Revenue vs cost (\$/MWh) bar chart over time |
| [`ProductionCostChart`](/guides/ui/react/compose-reporting-layouts/cost/#productioncostchart) | Production cost over time, overlaid with BTC price |
| [`OperationsEnergyChart`](/guides/ui/react/compose-reporting-layouts/cost/#operationsenergychart) | Doughnut breakdown of operations vs energy cost |
### `CostContent`
Renders the data-driven portion of the Cost page in a 2x2 Mosaic grid (production cost chart, operations energy chart, avg all-in cost chart, and cost metric tiles). Building block of the Cost composite.
```tsx
```
Renders inside the [`Cost`](/reference/ui/components/dashboards#cost) composite.
### `CostMetrics`
Three \$/MWh tiles that summarise the cost-summary period. Order mirrors the OSS Cost page: All-in (highlighted), Energy, Operations. Building block of the Cost composite.
```tsx
```
Renders inside the [`Cost`](/reference/ui/components/dashboards#cost) composite.
### `AvgAllInCostChart`
Avg All-in Cost - revenue vs cost (\$/MWh) bar chart over time. Building block of the Cost composite.
```tsx
```
Renders inside the [`Cost`](/reference/ui/components/dashboards#cost) composite.
### `ProductionCostChart`
Production cost over time, overlaid with BTC price. Building block of the Cost composite.
```tsx
```
Renders inside the [`Cost`](/reference/ui/components/dashboards#cost) composite.
### `OperationsEnergyChart`
Doughnut breakdown of Operations vs Energy cost (in USD totals). Building block of the Cost composite.
```tsx
```
Renders inside the [`Cost`](/reference/ui/components/dashboards#cost) composite.
# Compose EBITDA layouts (/guides/ui/react/compose-reporting-layouts/ebitda)
@tetherto/mdk-react-devkit/foundation
The [`Ebitda`](/reference/ui/components/charts#ebitda) composite renders a fixed EBITDA layout (metric row plus chart panel). To build a custom arrangement, compose it from the building blocks below — each takes pre-shaped data as props and does no fetching.
## Building blocks
| Component | Description |
| --- | --- |
| [`EbitdaMetrics`](/guides/ui/react/compose-reporting-layouts/ebitda/#ebitdametrics) | Top row of EBITDA summary metric cards |
| [`EbitdaCharts`](/guides/ui/react/compose-reporting-layouts/ebitda/#ebitdacharts) | Revenue, cost, and EBITDA chart panel |
| [`ActualEbitdaCard`](/guides/ui/react/compose-reporting-layouts/ebitda/#actualebitdacard) | Realised EBITDA stat card vs prior period |
| [`EbitdaHodlCard`](/guides/ui/react/compose-reporting-layouts/ebitda/#ebitdahodlcard) | Projected EBITDA if all BTC is held |
| [`EbitdaSellingCard`](/guides/ui/react/compose-reporting-layouts/ebitda/#ebitdasellingcard) | Projected EBITDA if all BTC is sold |
| [`MonthlyEbitdaChart`](/guides/ui/react/compose-reporting-layouts/ebitda/#monthlyebitdachart) | EBITDA-by-month trend bar chart |
| [`BitcoinPriceCard`](/guides/ui/react/compose-reporting-layouts/ebitda/#bitcoinpricecard) | BTC reference-price stat card |
| [`BitcoinProducedCard`](/guides/ui/react/compose-reporting-layouts/ebitda/#bitcoinproducedcard) | Bitcoin-produced stat card with prior-period delta |
| [`BitcoinProducedChart`](/guides/ui/react/compose-reporting-layouts/ebitda/#bitcoinproducedchart) | Daily bitcoin-produced time-series chart |
| [`BitcoinProductionCostCard`](/guides/ui/react/compose-reporting-layouts/ebitda/#bitcoinproductioncostcard) | Avg USD cost to produce one bitcoin |
### `EbitdaMetrics`
Row of summary metric cards across the top of the EBITDA section (actual, hodl, selling, cost). Building block of the Ebitda composite.
```tsx
```
Renders inside the [`Ebitda`](/reference/ui/components/charts#ebitda) composite.
### `EbitdaCharts`
Chart panel inside the EBITDA section visualising revenue, cost, and EBITDA over time. Building block of the Ebitda composite.
```tsx
```
Renders inside the [`Ebitda`](/reference/ui/components/charts#ebitda) composite.
### `ActualEbitdaCard`
Stat card summarising the realised EBITDA for the selected reporting window vs the prior period. Building block of the Ebitda composite.
```tsx
```
Renders inside the [`EbitdaMetrics`](#ebitdametrics) row.
### `EbitdaHodlCard`
Stat card projecting EBITDA assuming all produced bitcoin is held instead of sold. Building block of the Ebitda composite.
```tsx
```
Renders inside the [`EbitdaMetrics`](#ebitdametrics) row.
### `EbitdaSellingCard`
Stat card projecting EBITDA assuming all produced bitcoin is sold at the daily reference price. Building block of the Ebitda composite.
```tsx
```
Renders inside the [`EbitdaMetrics`](#ebitdametrics) row.
### `MonthlyEbitdaChart`
Bar chart comparing EBITDA across the most recent months for trend visualisation. Building block of the Ebitda composite.
```tsx
```
Renders inside the [`EbitdaCharts`](#ebitdacharts) panel.
### `BitcoinPriceCard`
Stat card showing the BTC reference price used by the reporting view with currency and timestamp. Building block of the Ebitda composite.
```tsx
```
Renders inside the [`Ebitda`](/reference/ui/components/charts#ebitda) composite.
### `BitcoinProducedCard`
Stat card summarising the bitcoin produced during the reporting window with delta to prior period. Building block of the Ebitda composite.
```tsx
```
Renders inside the [`Ebitda`](/reference/ui/components/charts#ebitda) composite.
### `BitcoinProducedChart`
Time-series chart of bitcoin produced per day across the selected reporting window. Building block of the Ebitda composite.
```tsx
```
Renders inside the [`Ebitda`](/reference/ui/components/charts#ebitda) composite.
### `BitcoinProductionCostCard`
Stat card showing the average cost in USD to produce one bitcoin during the reporting window. Building block of the Ebitda composite.
```tsx
```
Renders inside the [`Ebitda`](/reference/ui/components/charts#ebitda) composite.
# Compose Energy balance layouts (/guides/ui/react/compose-reporting-layouts/energy-balance)
@tetherto/mdk-react-devkit/foundation
The [`EnergyBalance`](/reference/ui/components/charts#energybalance) composite renders a fixed two-tab layout (revenue and cost). To build a custom arrangement, compose it from the building blocks below — each takes pre-shaped data as props and does no fetching.
## Building blocks
| Component | Description |
| --- | --- |
| [`EnergyBalanceRevenueCharts`](/guides/ui/react/compose-reporting-layouts/energy-balance/#energybalancerevenuecharts) | Energy revenue tab chart mosaic |
| [`EnergyBalanceRevenueMetrics`](/guides/ui/react/compose-reporting-layouts/energy-balance/#energybalancerevenuemetrics) | Energy revenue stat-card grid |
| [`EnergyBalanceCostCharts`](/guides/ui/react/compose-reporting-layouts/energy-balance/#energybalancecostcharts) | Energy cost tab chart layout |
| [`EnergyBalanceCostMetrics`](/guides/ui/react/compose-reporting-layouts/energy-balance/#energybalancecostmetrics) | Energy cost stat-card grid |
| [`EnergyBalancePowerChart`](/guides/ui/react/compose-reporting-layouts/energy-balance/#energybalancepowerchart) | Power-vs-threshold line chart |
| [`EnergyRevenueChart`](/guides/ui/react/compose-reporting-layouts/energy-balance/#energyrevenuechart) | Energy revenue per MWh bar chart |
| [`EnergyCostChart`](/guides/ui/react/compose-reporting-layouts/energy-balance/#energycostchart) | Revenue vs cost per MWh bar chart |
| [`EnergyMetricCard`](/guides/ui/react/compose-reporting-layouts/energy-balance/#energymetriccard) | Single energy-balance metric stat card |
### `EnergyBalanceRevenueCharts`
Mosaic layout of revenue, downtime, and power charts for the energy balance revenue tab. Building block of the EnergyBalance composite.
```tsx
```
Renders inside the revenue tab of [`EnergyBalance`](/reference/ui/components/charts#energybalance).
### `EnergyBalanceRevenueMetrics`
Grid of stat cards summarising energy revenue metrics for the selected period. Building block of the EnergyBalance composite.
```tsx
```
Renders inside the revenue tab of [`EnergyBalance`](/reference/ui/components/charts#energybalance).
### `EnergyBalanceCostCharts`
Layout container for the energy cost tab charts: revenue-vs-cost bar chart and power line chart. Building block of the EnergyBalance composite.
```tsx
```
Renders inside the cost tab of [`EnergyBalance`](/reference/ui/components/charts#energybalance).
### `EnergyBalanceCostMetrics`
Grid of stat cards summarising energy cost metrics for the selected period. Building block of the EnergyBalance composite.
```tsx
```
Renders inside the cost tab of [`EnergyBalance`](/reference/ui/components/charts#energybalance).
### `EnergyBalancePowerChart`
Line chart visualising power consumption against threshold for the energy balance view. Building block of the EnergyBalance composite.
```tsx
```
Renders inside both tabs of [`EnergyBalance`](/reference/ui/components/charts#energybalance).
### `EnergyRevenueChart`
Bar chart showing site energy revenue per MWh, with USD/BTC currency toggle. Building block of the EnergyBalance composite.
```tsx
```
Renders inside [`EnergyBalanceRevenueCharts`](#energybalancerevenuecharts).
### `EnergyCostChart`
Bar chart comparing site revenue vs cost per MWh, with USD/BTC currency toggle. Building block of the EnergyBalance composite.
```tsx
```
Renders inside [`EnergyBalanceCostCharts`](#energybalancecostcharts).
### `EnergyMetricCard`
Stat card for a single energy balance metric. Building block of the EnergyBalance composite.
```tsx
```
Renders inside the energy-balance metric grids.
# Compose Hash balance layouts (/guides/ui/react/compose-reporting-layouts/hash-balance)
@tetherto/mdk-react-devkit/foundation
The [`HashBalance`](/reference/ui/components/dashboards#hashbalance) composite renders a fixed two-tab layout (revenue and cost). To build a custom arrangement, compose it from the two tab panels below — each takes pre-shaped data as props and does no fetching.
## Building blocks
| Component | Description |
| --- | --- |
| [`HashBalanceRevenuePanel`](/guides/ui/react/compose-reporting-layouts/hash-balance/#hashbalancerevenuepanel) | Hash balance revenue tab panel |
| [`HashBalanceCostPanel`](/guides/ui/react/compose-reporting-layouts/hash-balance/#hashbalancecostpanel) | Hash balance cost tab panel |
### `HashBalanceRevenuePanel`
Revenue tab panel for hash balance - site hash revenue, network hashrate, hashprice charts, and currency toggle for per-PH/day units. Building block of the HashBalance composite.
```tsx
```
Renders inside the revenue tab of [`HashBalance`](/reference/ui/components/dashboards#hashbalance).
### `HashBalanceCostPanel`
Cost tab panel for hash balance - metric tiles and combined cost / revenue / network hashprice bar chart for the selected period. Building block of the HashBalance composite.
```tsx
```
Renders inside the cost tab of [`HashBalance`](/reference/ui/components/dashboards#hashbalance).
# Compose Hashrate layouts (/guides/ui/react/compose-reporting-layouts/hashrate)
@tetherto/mdk-react-devkit/foundation
The [`Hashrate`](/reference/ui/components/charts#hashrate) composite renders a fixed three-tab layout. To build a custom arrangement, compose it from the tab views below — each takes pre-shaped data as props and does no fetching.
## Building blocks
| Component | Description |
| --- | --- |
| [`HashrateSiteView`](/guides/ui/react/compose-reporting-layouts/hashrate/#hashratesiteview) | Site-level hashrate trend view |
| [`HashrateMinerTypeView`](/guides/ui/react/compose-reporting-layouts/hashrate/#hashrateminertypeview) | Hashrate grouped by miner model |
| [`HashrateMiningUnitView`](/guides/ui/react/compose-reporting-layouts/hashrate/#hashrateminingunitview) | Hashrate grouped by mining unit |
### `HashrateSiteView`
Site-level hashrate trend - aggregates hashrate across the whole site for the selected date range, with an optional miner-type filter that scopes the sum to a subset. Building block of the Hashrate composite.
```tsx
```
Renders inside the Site View tab of [`Hashrate`](/reference/ui/components/charts#hashrate).
### `HashrateMinerTypeView`
Hashrate drilldown grouped by miner model - bar chart of the latest hashrate per miner type, with an optional multi-select filter. Building block of the Hashrate composite.
```tsx
```
Renders inside the Miner Type View tab of [`Hashrate`](/reference/ui/components/charts#hashrate).
### `HashrateMiningUnitView`
Hashrate drilldown grouped by mining unit / container - bar chart of the latest hashrate per container with an optional multi-select filter. Building block of the Hashrate composite.
```tsx
```
Renders inside the Mining Unit View tab of [`Hashrate`](/reference/ui/components/charts#hashrate).
# Compose spare parts inventory flows (/guides/ui/react/compose-spare-parts-inventory-flows)
## Overview
@tetherto/mdk-react-devkit
The spare parts inventory composes from seven [Dialog components](/reference/ui/components/dialogs) that cover registering a part,
keeping its subtypes current, moving it (alone or in a batch), reviewing where it has been, and retiring it.
Each dialog receives its data and options as props and does no fetching of its own, so you wire the data layer and API calls
yourself. This guide walks through composing them into one workflow.
## Prerequisites
Complete the [installation](/guides/ui/install) and import styles: `import '@tetherto/mdk-react-devkit/styles.css'`.
## How the pieces fit together
```mermaid
flowchart LR
subtypes["Manage subtypes"]
addOne["Add one part"]
bulkAdd["Bulk-add via CSV"]
inventory["Spare part in inventory"]
moveOne["Move one part"]
moveMany["Move many parts"]
history["View movement history"]
delete["Delete part"]
subtypes -.-> addOne
addOne --> inventory
bulkAdd --> inventory
inventory --> moveOne
inventory --> moveMany
moveOne --> history
moveMany --> history
inventory --> delete
```
- [`AddSparePartModal`](/reference/ui/components/dialogs#addsparepartmodal): registers a single new spare part
- [`SparePartSubTypesModal`](/reference/ui/components/dialogs#sparepartsubtypesmodal): views and adds part-model subtypes for a part type
- [`BulkAddSparePartsModal`](/reference/ui/components/dialogs#bulkaddsparepartsmodal): registers many parts at once from a CSV file
- [`MoveSparePartModal`](/reference/ui/components/dialogs#movesparepartmodal): moves a single part to a new location or status
- [`BatchMoveSparePartsModal`](/reference/ui/components/dialogs#batchmovesparepartsmodal): moves several selected parts to a new location or status in one submit
- [`MovementDetailsModal`](/reference/ui/components/dialogs#movementdetailsmodal): shows the origin-to-destination detail of a historical move
- [`ConfirmDeleteSparePartModal`](/reference/ui/components/dialogs#confirmdeletesparepartmodal): confirms an irreversible delete
Two of these pieces are coupled rather than independent:
- [`AddSparePartModal`](/reference/ui/components/dialogs#addsparepartmodal) can embed [`SparePartSubTypesModal`](/reference/ui/components/dialogs#sparepartsubtypesmodal)
through its `subTypes*` props. This lets someone add a missing part model without losing the in-progress Add form.
`SparePartSubTypesModal` also works standalone, opened directly rather than through Add
- [`MoveSparePartModal`](/reference/ui/components/dialogs#movesparepartmodal) and [`BatchMoveSparePartsModal`](/reference/ui/components/dialogs#batchmovesparepartsmodal)
both move parts, but for a different cardinality: reach for `MoveSparePartModal` when a single row action moves one part
through an edit-then-confirm step, and for `BatchMoveSparePartsModal` when a multi-select table applies one new location
or status to every selected part in a single submit, with no confirmation step
## Walk through a typical flow
### Add a part
Open [`AddSparePartModal`](/reference/ui/components/dialogs#addsparepartmodal) from your own add-part entry point. Part-type tabs
drive which fields validate: a controller part type requires a MAC address, other part types require a serial number instead.
Supply `modelOptions` for the active part type and refetch them in `onPartTypeChange` when the tab changes.
### Maintain subtypes
If the part model someone needs is not in `modelOptions`, they can open [`SparePartSubTypesModal`](/reference/ui/components/dialogs#sparepartsubtypesmodal)
from inside Add without losing their progress, or you can open it standalone from an inventory settings surface. Either way, the
parent owns `activePartTypeId` and `subTypes` and re-supplies them when the tab changes.
### Bulk-add many parts instead
For registering many parts at once, use [`BulkAddSparePartsModal`](/reference/ui/components/dialogs#bulkaddsparepartsmodal) instead
of repeating the one-by-one Add flow. It offers a CSV template download, parses the selected file client-side, and submits the
parsed records through your `onSubmit` handler; CSV parsing and validation helpers are exported alongside the component for
wiring that handler up.
### Move a part, one or many
Move a single part with [`MoveSparePartModal`](/reference/ui/components/dialogs#movesparepartmodal): it previews the before-to-after
location and status transition before the user confirms. Move a multi-selected group with [`BatchMoveSparePartsModal`](/reference/ui/components/dialogs#batchmovesparepartsmodal),
which applies one new location and status to every part in the selection.
### View its movement history
[`MovementDetailsModal`](/reference/ui/components/dialogs#movementdetailsmodal) is read-only: pass it a historical `movement`
record and it renders the device summary alongside the origin-to-destination transition. It does not trigger a move itself, it
explains one that already happened.
### Delete a part
[`ConfirmDeleteSparePartModal`](/reference/ui/components/dialogs#confirmdeletesparepartmodal) gates the destructive path. It
surfaces the part code so the user can verify what they are about to remove, and disables its action buttons through `isLoading`
while the delete call is in flight.
## Next steps
- [Dialog components](/reference/ui/components/dialogs): full props reference for all seven components
- [React UI guides](/guides/ui/react): other guides for composing MDK React UI components
# UI CLI reference (/guides/ui/ui-cli)
The **UI CLI** (`mdk-ui`, package `@tetherto/mdk-ui-cli`) is the command surface your AI agent uses to build with MDK. You usually never run it yourself, your agent does, after you [wire your IDE](/tutorials/ui/react/build-any-dashboard-with-an-agent). This page documents the commands for when you want to drive or inspect the tooling by hand.
Every command runs locally and prints JSON by default. Add `--format table` for human-readable output. There are no network or model calls: each command is a lookup against files MDK ships.
## At a glance
| Bucket | Section | What it covers |
|--------|---------|----------------|
| Set up | [Set up a project](#set-up-a-project) | Wire your IDE with `init` |
| Discover | [Discover what to use](#discover-what-to-use) | `suggest`, `hooks`, `stores`, `find` |
| Read | [Read a component contract](#read-a-component-contract) | `docs`, `example` |
| Recipes | [Follow a recipe](#follow-a-recipe) | `blueprints`, `blueprint` |
| Scaffold | [Scaffold and verify](#scaffold-and-verify) | `add page`, `check`, `sync` |
| Inspect | [Inspect the UI CLI itself](#inspect-the-ui-cli-itself) | `--json-help` |
## All commands
| Command | Summary |
|---------|---------|
| [`init`](#init) | Bootstrap `.mdk/context.md` and IDE rules |
| [`suggest`](#suggest) | Ranked shortlist from free-text intent |
| [`hooks`](#hooks) | List adapter hooks (optional `--category`) |
| [`stores`](#stores) | List Zustand stores and query helpers |
| [`find`](#find) | Filter components by domain and capability |
| [`docs`](#docs) | Print a component's `USAGE.md` |
| [`example`](#example) | Print a runnable `*.example.tsx` |
| [`blueprints`](#blueprints) | List curated intent-to-component recipes |
| [`blueprint`](#blueprint) | Show one recipe in detail |
| [`add page`](#add-page) | Scaffold a page with chosen components |
| [`check`](#check) | Type-check a file against real APIs |
| [`sync`](#sync) | Refresh `.mdk/context.md` |
| [`--json-help`](#json-help) | Machine-readable CLI surface |
## The agent's decision flow
Given an intent, the deterministic path a session follows is:
```mermaid
flowchart TD
intent["Plain-language intent"]
suggest["mdk-ui suggest"]
state{"State or hooks needed?"}
hooks["mdk-ui hooks / stores"]
blueprints["mdk-ui blueprints"]
match{"Matching blueprint?"}
blueprint["mdk-ui blueprint"]
find["mdk-ui find"]
docs["mdk-ui docs / example"]
add["mdk-ui add page"]
check["mdk-ui check"]
intent --> suggest
suggest --> state
state -->|yes| hooks
state -->|no| blueprints
hooks --> add
blueprints --> match
match -->|yes| blueprint
match -->|no| find
blueprint --> docs
find --> docs
docs --> add
add --> check
```
## Set up a project
### init
Bootstraps the current project with an agent-context file and an IDE rule so every AI session is wired automatically:
```bash
npx @tetherto/mdk-ui-cli init --ide cursor # .mdk/context.md + .cursor/rules/mdk.mdc
npx @tetherto/mdk-ui-cli init --ide claude # .mdk/context.md + CLAUDE.md
```
## Discover what to use
### suggest
Turns free text into a ranked shortlist across components, hooks, blueprints, and stores:
```bash
mdk-ui suggest "show hashrate for a pool"
```
### hooks
Lists every hook exported from `@tetherto/mdk-react-adapter`, grouped by category (`store`, `utility`, `permission`, `ui`, `external`):
```bash
mdk-ui hooks --format table # all adapter hooks
mdk-ui hooks --category store --format table # store-binding hooks only
```
### stores
Describes the Zustand stores and TanStack Query helpers from `@tetherto/mdk-ui-foundation`:
```bash
mdk-ui stores --format table # stores and query helpers
mdk-ui stores --category devices --format table
```
### find
Filters the component library by domain and capability:
```bash
mdk-ui find --domain mining-operations --capability hashrate-monitoring
```
## Read a component contract
### docs
Prints a component's usage notes:
```bash
mdk-ui docs LineChartCard
```
### example
Prints a runnable example:
```bash
mdk-ui example LineChartCard
```
## Follow a recipe
Blueprints are curated recipes that map a high-level intent to a concrete set of components and hooks.
### blueprints
Lists available recipes:
```bash
mdk-ui blueprints
```
### blueprint
Shows one recipe in detail:
```bash
mdk-ui blueprint device-management
```
## Scaffold and verify
### add page
Scaffolds a page with the components you name:
```bash
mdk-ui add page Dashboard --component LineChartCard
```
### check
Confirms a file compiles against the real component APIs:
```bash
mdk-ui check src/pages/Dashboard.tsx
```
### sync
Keeps the `.mdk/context.md` agent-context file current as MDK updates:
```bash
mdk-ui sync
```
## Inspect the UI CLI itself
### json-help
`--json-help` prints the full command surface, useful for meta-tooling that wants to discover commands without running them:
```bash
mdk-ui --json-help
```
## Next steps
- [Build dashboards with your AI agent](/tutorials/ui/react/build-any-dashboard-with-an-agent): the two-step flow most developers use
- [UI Devkit](/reference/ui): the component library these commands draw from
- [MDK repositories](/support/resources/repositories): source for the UI CLI and the agent-ready contract
# 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](https://github.com/tetherto/mdk/blob/main/backend/workers/samples/demo-worker/src/client.js)
- 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](https://github.com/tetherto/mdk/blob/main/backend/workers/samples/demo-worker/mock/server.js)
- The [tests](https://github.com/tetherto/mdk/blob/main/backend/workers/samples/demo-worker/tests/unit/handlers.test.js)
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). [`demo-worker-caller`](https://github.com/tetherto/mdk/blob/main/examples/backend/demo-worker-caller/index.js)
shows one host doing exactly that for this guide's own reference implementation.
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.
This guide generalizes one real, runnable reference implementation already in this repo:
[`backend/workers/samples/demo-worker/`](https://github.com/tetherto/mdk/blob/main/backend/workers/samples/demo-worker/package.json). It proves this pattern works with **zero**
dependency on 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, and security boundaries that the deliberately small sample does not implement.
This guide uses **partner integration** for the complete integration, **Worker plugin package** for the static
contract and handlers, **host process** for the Node.js process that owns [`WorkerRuntimeV2`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js), and **device ID** for a
runtime device identity.
## What you get
```text
your-worker-repo/
package.json
mdk-contract.json # the engineering + AI-context contract
src/
client.js # plain I/O against your vendor's native API, no MDK concepts
telemetry/*.js # one handler per telemetry field
commands/*.js # one handler per command
mock/
server.js # a standalone fake of the vendor's device API
tests/
unit/handlers.test.js # drives loadContract() + createInstance() against the mock
# (no WorkerRuntimeV2 involved)
```
This tree is [`demo-worker`](https://github.com/tetherto/mdk/blob/main/backend/workers/samples/demo-worker/package.json)'s own layout with its vendor name replaced by the placeholder `vendor`:
`demo-worker` itself builds and tests with **zero** dependency on `WorkerRuntimeV2`, and your package will too.
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 `demo-worker` 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)
- 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
### 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 will 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, but it
is **not yet published to the npm registry**. Install it 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 github:tetherto/mdk#main
(cd node_modules/@tetherto/mdk/backend/core && ./install-packages.sh)
```
This adds `"@tetherto/mdk": "github:tetherto/mdk#main"` to your `dependencies` and installs the whole monorepo under
`node_modules/@tetherto/mdk` (its own root `package.json` name); there is no package literally named
`@tetherto/mdk-worker` in `node_modules`. Step 7's test file accounts for this: it requires
`@tetherto/mdk/backend/core/mdk-worker`, the same deep path every in-repo Worker already uses. 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) is unaffected by any of
this: `WorkerRuntimeV2` intercepts that exact string before Node resolves it, so it works whether or not
`@tetherto/mdk-worker` exists anywhere in `node_modules`. Only the plain `require("@tetherto/mdk-worker")` calls in
your own test/verification scripts need the deep path above.
### 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.
`src/client.js`, modeled on [`demo-worker`'s own `client.js`](https://github.com/tetherto/mdk/blob/main/backend/workers/samples/demo-worker/src/client.js):
```js
'use strict'
const { opts, env, logger } = require('@tetherto/mdk-worker/device')
logger('config received: opts=%o', opts)
const TIMEOUT_MS = opts.timeoutMs || 5000
const base = `http://${opts.host || '127.0.0.1'}:${opts.port}`
const auth = env.DEVICE_TOKEN
? { authorization: `Bearer ${env.DEVICE_TOKEN}` }
: {}
const call = async (path, callOpts = {}) => {
try {
const res = await fetch(base + path, {
...callOpts,
headers: { ...auth, ...callOpts.headers },
signal: callOpts.signal || AbortSignal.timeout(TIMEOUT_MS)
})
const body = await res.json()
if (!res.ok || body.ok === false) {
throw new Error(body.error || `ERR_DEVICE_CALL_FAILED: ${res.status}`)
}
return body
} catch (err) {
if (err.name === 'TimeoutError') { throw new Error(`ERR_DEVICE_TIMEOUT: ${path}`) }
throw err
}
}
module.exports = {
getSummary: () => call('/api/v1/summary'),
reboot: () => call('/api/v1/reboot', { method: 'POST' }),
setPowerMode: (mode) =>
call('/api/v1/power-mode', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode })
})
}
```
Whatever your device speaks — HTTP + digest auth, Modbus TCP, MQTT, a binary serial protocol — it lives entirely in this
one file. Everything downstream only ever calls the methods this returns.
Use a finite timeout for every device operation and propagate cancellation when the underlying client supports it.
Retry idempotent telemetry reads only when the device protocol makes that safe, with bounded exponential backoff and
structured logging owned by the host process. Do **not** automatically retry physical commands: a timeout can mean
the command succeeded but its response was lost, so retrying can duplicate the operation.
This module loads once, when `WorkerRuntimeV2` opens this device's context, and stays loaded for the life of the
process; nothing probes the device up front. An unreachable device does not fail at load time; the failure
surfaces from the first handler call that actually reaches the network (see Step 4).
### Declare the contract
`mdk-contract.json` is the static source of truth for what telemetry your Worker reports, what commands it accepts,
and the semantic context an AI agent or human operator needs to use it safely. The
[formal JSON Schema](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) describes this handler-bearing source contract.
Runtime device IDs and connection config belong to the host process and are reported dynamically during identity
registration; they are deliberately not embedded in the plugin contract.
`mdk-contract.json`, at your package root:
```json
{
"metadata": {
"provider": "vendor",
"deviceFamily": "miner",
"brand": "Vendor",
"modelsSupported": ["VENDOR_Q1"],
"overview": "Controls Vendor miners running firmware v1's HTTP JSON API. Operations affect physical hardware — prioritize thermal safety."
},
"capabilities": {
"telemetry": [
{
"name": "hashrate_rt",
"unit": "TH/s",
"type": "number",
"handler": "src/telemetry/hashrate-rt.js",
"description": "Real-time hashrate from /api/v1/summary."
},
{
"name": "power",
"unit": "W",
"type": "number",
"handler": "src/telemetry/power.js",
"description": "Current power draw."
},
{
"name": "temperature",
"unit": "C",
"type": "number",
"handler": "src/telemetry/temperature.js",
"description": "Hash board temperature. Above 85C requires intervention."
}
],
"commands": [
{
"name": "reboot",
"handler": "src/commands/reboot.js",
"description": "Restarts the miner controller.",
"constraints": "Do not call more than once per 5 minutes.",
"params": []
},
{
"name": "setPowerMode",
"handler": "src/commands/set-power-mode.js",
"description": "Changes the power mode.",
"params": [
{
"name": "mode",
"type": "string",
"required": true,
"enum": ["eco", "normal", "high"]
}
]
}
],
"health": {
"supportedStates": ["OK", "DEGRADED", "OFFLINE"],
"alerts": ["alert.overheat"],
"troubleshooting": [
"If alert.overheat, verify fan speeds and ambient temperature before rebooting."
]
},
"errors": {
"ERR_MODE_REQUIRED": "The requesting client omitted the required power mode.",
"ERR_MODE_TYPE": "The supplied power mode was not a string.",
"ERR_BAD_POWER_MODE": "The supplied power mode is not allowed or the firmware rejected it.",
"ERR_COMMAND_COOLDOWN": "The command was issued before its declared cooldown elapsed.",
"ERR_COMMAND_IN_PROGRESS": "A command of this type is already running for the device.",
"ERR_DEVICE_TIMEOUT": "The device operation exceeded its configured timeout.",
"ERR_DEVICE_CALL_FAILED": "The v1 HTTP API call failed or returned an error."
}
}
}
```
A few fields worth calling out because they aren't just documentation:
- `description` is read by AI agents as the semantic boundary for that field — put the actual constraint in it (e.g.
_"Above 85C requires intervention"_), not just a label
- `params`, `enum`, numeric ranges, and `constraints` are published metadata; `WorkerRuntimeV2` normalizes positional
parameters but does not validate or enforce them. The command handler must reject missing, wrong-type, out-of-range,
or disallowed values with stable `ERR_*` failures and enforce every declared cooldown.
- `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 will fail.
#### 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; [`demo-worker`'s own `db.js`](https://github.com/tetherto/mdk/blob/main/backend/workers/samples/demo-worker/src/db.js) shows the same per-device-instance pattern applied
to a local SQLite file. The JSON audit lines demonstrate the minimum event shape, including rejected and
failed outcomes; production hosts must send these events to a durable audit sink. Actor identity and request
correlation are owned by the authenticated Gateway/control plane because they are not currently present in the
handler arguments. Never include credentials or raw device responses in audit events.
Telemetry routing uses `query.type`, not the contract entry's return `type`. A request with
`{ query: { type: "metrics" } }` invokes **every** telemetry handler and returns
`{ metrics: { hashrate_rt: value, history: value, ... } }`; each handler error is isolated as
`{ error: "..." }` under that key. A request with `{ query: { type: "history", limit: 20 } }` invokes only the
telemetry entry named `history` and returns `{ name: "history", value }` or `{ error }`. The contract's
`"type": "array"` describes the handler's returned value; it does not create the channel. A history-like handler is
still included in the default `metrics` loop under the current runtime, so keep it bounded and inexpensive or
change the runtime contract before relying on different behavior. Keep named-channel handlers defensive as callers
can invoke them directly with untrusted query fields.
[`WorkerRuntimeV2`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js) also auto-registers a builtin `health` channel on every device, with no contract
entry required: a plugin that doesn't declare its own `health` telemetry handler still answers
`{ query: { type: "health" } }` with `{ status: "OK", id, opts, env, config, workerId }`. Declaring a `health` entry in
[`capabilities.telemetry`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) yourself overrides the builtin with your own handler.
```js
await mdkClient.pullTelemetry(deviceId, 'health')
// → { status: 'OK', id: 'wm-001', opts: {...}, env: {...}, config: {...}, workerId: '...' }
```
### Verify the plugin loads
There is nothing left to assemble: `mdk-contract.json` at your package root, together with the handler files it
declares under `src/`, is the complete, loadable Worker plugin. No index file exports it, and nothing turns it into
an object for a runtime to consume; a host points `WorkerRuntimeV2` straight at your package directory.
That does mean a broken handler wiring has nowhere to surface until something tries to load the directory. Catch it
yourself with [`loadContract`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/index.js), the same function `WorkerRuntimeV2` calls internally:
```js
'use strict'
const { loadContract } = require('@tetherto/mdk/backend/core/mdk-worker')
const loaded = loadContract(__dirname)
console.log(loaded.publishedContract) // handler paths stripped, the shape Kernel receives
```
`loadContract` resolves every declared `handler` path on disk but never executes it. A missing file, a missing
`handler` field, or a duplicate name throws immediately (see Troubleshooting). It cannot yet catch a handler file
that exists but fails to load or does not export a function: that only happens once a device instance is built from
it, which is what Step 7's tests exercise per handler.
Every declared device reports `online` immediately; an unreachable one surfaces as an error inside the telemetry
payload rather than holding the device `offline`. Whatever a handler module opens at load time (a socket, a file
handle) lives until the process exits; nothing closes it automatically.
### Build a mock device
Ship a standalone fake of your vendor's native API so anyone (including your own CI) can develop and test against your
Worker without real hardware. It should know nothing about MDK; it's the same surface a real device on the LAN would
present.
`mock/server.js`, modeled on [`demo-worker/mock/server.js`](https://github.com/tetherto/mdk/blob/main/backend/workers/samples/demo-worker/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. `demo-worker`'s own
[`tests/unit/handlers.test.js`](https://github.com/tetherto/mdk/blob/main/backend/workers/samples/demo-worker/tests/unit/handlers.test.js) is the complete worked example of this style; the harness
below is the same pattern trimmed to this guide's contract.
```js
'use strict'
const path = require('path')
const test = require('brittle')
const { loadContract, createInstance } = require('@tetherto/mdk/backend/core/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 (as `demo-worker`'s own test file does) 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) — 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
- Test your new [Worker's integration with MDK](/guides/workers/test-a-worker)
- Understand the 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 with MDK (/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
```
This mirrors [`examples/backend/demo-worker-caller/`](https://github.com/tetherto/mdk/blob/main/examples/backend/demo-worker-caller/index.js), which is an
example directory containing one host module, not a standalone npm package.
## Prerequisites
- Node.js `>=24` (all MDK core packages declare this `engines` constraint)
- 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
### Install MDK
`@tetherto/mdk-worker` (the package that ships `WorkerRuntimeV2`) is **not yet published to the npm registry** — MDK is
pre-1.0 and still distributed as this monorepo. Until it is, the working path from an external repo is a git
dependency plus a deep `require()` into the checked-out repo, exactly mirroring how every in-repo Worker already
resolves it (by relative path, not through `node_modules` package resolution):
```bash
npm install github:tetherto/mdk#main
```
This installs the whole monorepo under `node_modules/@tetherto/mdk` (its root `package.json` name). It does **not**
auto-install the nested package's own dependencies — this repo's install is a federated set of scripts, not a single
root dependency graph — so run its installer once after adding it:
```bash
(cd node_modules/@tetherto/mdk/backend/core && ./install-packages.sh)
```
The same deep-path pattern also gets you `getKernel`, `startGateway`, and `waitForDiscovery` from
`require('@tetherto/mdk/backend/core/mdk')`, used in Step 3 below.
### Write the host module
`host/index.js`, modeled on
[`examples/backend/demo-worker-caller/index.js`](https://github.com/tetherto/mdk/blob/main/examples/backend/demo-worker-caller/index.js):
```js
"use strict";
const path = require("path");
const { WorkerRuntimeV2 } = require("@tetherto/mdk/backend/core/mdk-worker");
const WORKER_DIR = path.resolve(__dirname, "../your-worker-repo");
async function startVendorWorker({ workerId, kernelTopic, seedDevices }) {
const runtime = new WorkerRuntimeV2(WORKER_DIR, {
workerId,
kernelTopic: kernelTopic || null,
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` and a non-empty `devices` array. 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.
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.
See the [demo host module](https://github.com/tetherto/mdk/blob/main/examples/backend/demo-worker-caller/index.js) for a SQLite sampler example.
`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/backend/core/mdk");
const { createMdkClient } = require("@tetherto/mdk/backend/core/client");
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",
seedDevices: [
{ id: "vendor-0", opts: { host: "127.0.0.1", port: 9001 } },
],
});
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");
}
client = createMdkClient({ hrpc: { key: kernel.getPublicKey() } });
await client.connect();
const telemetry = await withTimeout(
client.pullTelemetry("vendor-0", "metrics"),
8000,
"ERR_TELEMETRY_TIMEOUT",
);
if (typeof telemetry.metrics?.hashrate_rt !== "number") {
throw new Error("ERR_TELEMETRY_INVALID");
}
console.log(`READY ${ready.workerId}: ${ready.deviceIds.join(", ")}`);
console.log(`hashrate_rt=${telemetry.metrics.hashrate_rt}`);
} finally {
if (client) await client.close();
if (kernel) await shutdown(kernel);
if (worker) await worker.stop();
if (mock) mock.exit();
}
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
```
Expected output:
```text
READY vendor-demo: vendor-0
hashrate_rt=200
```
The timeout wrapper bounds the client's wait but cannot cancel the current HRPC request. Always close the client
during shutdown. Device-protocol cancellation is separately owned by the device client from Step 2.
### Use DHT discovery across processes or hosts
For DHT discovery, generate and securely distribute one 32-byte hex topic, start the Worker first with
`kernelTopic`, then start Kernel with the same `topic`. 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/backend/core/mdk");
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 {
worker = await startVendorWorker({
workerId: "vendor-demo",
kernelTopic: topic,
seedDevices: [
{ id: "vendor-0", opts: { host: "10.0.0.20", port: 9001 } },
],
});
kernel = await getKernel({ root: ROOT, topic });
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 for DHT, Local, and Same-process trade-offs.
## 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_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 legacy services](https://github.com/tetherto/mdk/blob/main/docs/reference/maintainers/worker-runtime-legacy-services.md)) 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 and verify both processes use the same 32-byte hex topic and can reach the DHT network |
| 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 or 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
Understand the end-user experience of controlling and monitoring your device via the Worker:
- Build a [minimal dashboard](/tutorials/build-a-dashboard) around one Worker
- Run the [Starter site example](https://github.com/tetherto/mdk/blob/main/examples/mvp-site/README.md) with a supervised, multi-Worker fleet
- Connect [the operator agent](/guides/agent) to query and command your Workers over MCP
# Reference (/reference)
The Reference section indexes the canonical specs for everything MDK exposes: field semantics, signatures, transition
rules, and contracts. Reach for it when you need exact shapes.
## Browse by stack area
### App Toolkit
- **UI Devkit**: [components](/reference/ui/components/), [hooks](/reference/ui/hooks/), [types](/reference/ui/types/), and
[utilities](/reference/ui/utilities/) for the React UI Devkit
### Kernel
- **[Kernel](/reference/kernel/)**: kernel module specs, state machines, transition tables, and recovery behavior
### MDK Protocol
- **[Protocol](/reference/protocol/)**: envelope schema, request/response examples, action catalogue, and
base command set
- *Capability contract*: coming soon
### Hardware
- **[Supported hardware](/reference/supported-hardware/)**: miners, containers, power meters, sensors,
and mining-pool integrations
### Workers
- **[Workers](/reference/worker/)**: device protocol adapters that wrap vendor hardware APIs and expose them through the MDK Protocol
## Next steps
- [Architecture](/concepts/architecture) for narrative explanations
- [Try the demo](/tutorials/run-a-site) for step-by-step instructions
# Glossary (/reference/glossary)
This page provides explanations for terms that new users may not be familiar with.
- [Stack](#stack-and-hardware-terms)
- [HRPC](#hyperswarm-rpc)
## Stack and hardware terms
This section explains the terms you need to familiarize yourself with, using an Antminer rack as an example.
| Term | What it is | Lives at |
| --- | --- | --- |
| **Kernel** (Orchestration Kernel) | The pull-only kernel that owns the device registry, routes commands, and aggregates telemetry. | [`backend/core/kernel/index.js`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/index.js) |
| **Gateway** | The developer-owned entry point between non-Node clients (UI, AI agents) and Kernel. Mandatory whenever a non-Node consumer reaches the kernel; not used in the in-process Antminer-rack example below. | [`backend/core/gateway/`](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/worker.js) |
| **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/docs/install-pattern.md`](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/install-pattern.md) |
| **Manager class** | The JavaScript class a Worker exports, one per supported device model. Instances drive a single rack of devices. | e.g. `AM_S19XP`, `AM_S21` in [`backend/workers/miners/antminer/index.js`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/antminer/index.js) |
| **Thing** | One registered device instance. Created by calling `manager.registerThing({ info, opts })`. Identified by a generated `deviceId`. | runtime, in `manager.mem.things` |
| **MCP** (Model Context Protocol) | The protocol AI agents use to discover and call tools. MDK's server is a standalone package — a separate process from the Gateway, not a Gateway plugin. | [`backend/core/mcp/`](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) |
### How they compose, for an Antminer rack
```mermaid
flowchart TB
subgraph clientLayer ["Your code"]
Client["your script (e.g., client.js)"]
end
subgraph kernel ["Kernel"]
Kernel["Kernel device registry · command routing · telemetry pull"]
end
subgraph workerLayer ["Antminer Worker"]
AntminerWorker["e.g., AM_S21PRO"]
end
subgraph devices ["Antminer devices (real or mock)"]
Miners["Antminers (HTTP / digest auth)"]
end
Client -->|"HRPC"| Kernel
Kernel -->|"HRPC"| AntminerWorker
AntminerWorker --> Miners
```
The same shape repeats for every other device family (Whatsminer, container vendors, pool APIs). [Scalability](/concepts/scalability) covers the
multi-Worker view, parallel Workers, and multi-site deployments.
## Hyperswarm RPC
MDK uses [`@hyperswarm/rpc`](https://github.com/holepunchto/rpc) as its runtime transport. Hyperswarm RPC (HRPC) is not an HTTP-based RPC system. It is an RPC layer
that rides on Hyperswarm peer-to-peer connectivity. The library is a simple RPC over the Hyperswarm DHT, backed by `Protomux`. Think of it as a peer-to-peer
remote function call system built on a DHT and an encrypted connection layer.
**Mental model** — Hyperswarm finds peers and establishes connections; `Protomux` divides the connection into named channels;
RPC defines the conversation — a caller names a method and receives a reply.
A useful analogy is a phone call between peers — Hyperswarm helps the phones find each other and connect; `Protomux` splits
the line into channels; RPC defines how one side asks for a method and the other side responds.
**Practical implications:**
- 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 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({ hrpc: { 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.
## 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
- Gateway implementation details, including HTTP routing and plugin registration: [`backend/core/gateway/worker.js`](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/worker.js)
- Building your own Worker for a new device family: see [`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/antminer/plugin/mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/antminer/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): maps `deviceId` to `workerId` to RPC channel, and drives each Worker through its registration lifecycle
- [`CommandDispatcher`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#commanddispatcher): validates an incoming command, resolves the target device or devices, and hands off to the Command State Machine
- [`CommandStateMachine`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#commandstatemachine): tracks every command's execution lifecycle in a write-ahead log
- [`TelemetryCollector`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#telemetrycollector): a stateless proxy that routes telemetry queries to the Worker that owns the data
- [`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 registered Worker on a cadence and marks dead ones unroutable
- [`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: the architectural narrative behind this module split
- Understand 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
# 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 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 hardware (/reference/supported-hardware)
## Overview
MDK integrates field hardware through Workers. Each Worker declares what it supports in its `mdk-contract.json`, and that contract is the single source of truth for coverage. Use this page to discover what Workers are supported.
## What MDK supports
- **Miners**: For example, Bitmain Antminer, MicroBT Whatsminer
- **Containers**: For example, Bitmain Antspace, Bitdeer
- **Power meters**: For example, ABB, Satec
- **Sensors**: For example, Seneca
- **Mining pools**: Protocol integrations such as Ocean, F2Pool
For the exact model lists, Worker packages, and per-Worker docs, see the generated catalogue:
- [Full supported-hardware catalogue](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/supported-hardware.md) — generated from every `backend/workers/**/mdk-contract.json`
## Next steps
- New to the moving parts? Read [terminology](/reference/glossary) (Kernel, Worker, manager, thing, mock)
- Decide how to run the Worker service — [Deployment topologies](/guides/deployment)
- Run a miner Worker — [Run a miner Worker](/guides/miners)
# 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 | 286 components |
| [Hooks](/reference/ui/hooks) | React hooks for state and data | 106 hooks |
| [Query Helpers](/reference/ui/query-helpers) | TanStack Query helpers for data fetching | 43 queryHelpers |
| [Stores](/reference/ui/stores) | Zustand stores for state management | 5 stores |
| [Types](/reference/ui/types) | TypeScript type definitions | 257 types |
| [Utilities](/reference/ui/utilities) | Helper functions and formatters | 149 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
```
## Getting 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
- Complete the installation
```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
```
- Add the dependency to your app's `package.json`
```json
{
"dependencies": {
"@tetherto/mdk-react-devkit": "*",
"@tetherto/mdk-react-adapter": "*",
"@tetherto/mdk-ui-foundation": "*"
}
}
```
> **Coming soon** — npm packages are not yet published. Use the monorepo setup for now.
```bash
npm install \
@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 |
| [Dashboards](/reference/ui/components/dashboards) | Pre-built dashboard compositions |
| [Dialogs](/reference/ui/components/dialogs) | Modal dialogs and confirmation prompts |
| [Display](/reference/ui/components/display) | Data display and formatting components |
| [Features](/reference/ui/components/features) | Feature-specific composite 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 |
| [Misc](/reference/ui/components/misc) | Utility and miscellaneous components |
| [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
- Complete the installation
```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
```
- Add the dependency to your app's `package.json`
```json
{
"dependencies": {
"@tetherto/mdk-react-devkit": "*",
"@tetherto/mdk-react-adapter": "*",
"@tetherto/mdk-ui-foundation": "*"
}
}
```
> **Coming soon** — npm packages are not yet published. Use the monorepo setup for now.
```bash
npm install \
@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 `