# 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`](/concepts/stack/workers#capability-contract) serves the UI (data labels), the orchestrator (validation rules), and AI agents (reasoning context).
One file, three audiences, no drift.
### The orchestration kernel
[Kernel](/concepts/stack/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**](/concepts/stack/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`](/concepts/stack/workers#capability-contract). No involvement from MDK maintainers needed.
- **Software developers**: build custom mining applications in any language, or leverage the
[MDK App Toolkit](/concepts/stack/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](/concepts/stack/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#mdk-stack) on the
Architecture page.
## AI-ready with unified intelligence
MDK is designed from the ground up for [AI-driven operations](/concepts/architecture#ai-agents-and-the-mcp-server). 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`](/concepts/stack/workers#capability-contract)) 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/architecture#scaling) 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](/concepts/stack/app-toolkit)
- [Connecting intelligent agents](/concepts/architecture#ai-agents-and-the-mcp-server)
# How agents build with MDK (/concepts/agents)
MDK is built so your AI coding agent can turn a plain-language prompt into a working dashboard, without you wiring components by
hand. This page explains *why* those results are trustworthy — the build-time flow an agent follows on your machine. For the
setup steps, see [Build dashboards with your AI agent](/tutorials/ui/react/build-any-dashboard-with-an-agent). For the *runtime* path where agents
drive a live fleet, see [AI agents and the MCP Server](/concepts/architecture#ai-agents-and-the-mcp-server).
## What your agent does for you
Behind a single prompt, the agent:
- Finds the right MDK components and hooks for your intent
- Wires real adapter hooks and state, with no guessed imports or props
- Scaffolds a page and verifies that it compiles
- Reaches for stable, supported components by default, so the result is something you can ship
The outcome: the agent pulls real metadata, real examples, and real types instead of hallucinating an API.
## How it works
MDK ships a small set of machine-readable files that describe every component, hook, and store. Your agent reads those local
files, makes no network or model calls of its own to discover them, and only reaches for exports that MDK marks as stable. Because
it works from generated metadata rather than guesswork, it does not invent props or imports.
```mermaid
flowchart LR
prompt["Your prompt"]
agent["AI agent"]
manifests["MDK local manifests"]
page["Scaffolded, verified page"]
prompt --> agent
agent --> manifests
manifests --> agent
agent --> page
```
For the full command surface those manifests power, see the [UI CLI reference](/guides/ui/ui-cli). The complete
contract that keeps the metadata honest lives in the [MDK repositories](/support/resources/repositories).
## Next steps
- [Build dashboards with your AI agent](/tutorials/ui/react/build-any-dashboard-with-an-agent): the two-step setup flow
- [Build a dashboard with an agent](/tutorials/ui/react/build-any-dashboard-with-an-agent): a full Stats Lab walkthrough with the UI CLI commands
- [UI CLI reference](/guides/ui/ui-cli): every command your agent (or you) can run
- [AI agents and the MCP Server](/concepts/architecture#ai-agents-and-the-mcp-server): the runtime path for live fleet control
# Architecture (/concepts/architecture)
Status: 🚧 MDK is in active development. This page describes the target architecture and may evolve as real-world implementations land.
## How MDK works
MDK is built around a small kernel with one job: route validated commands to whichever Worker owns a device, and pull telemetry
back. Everything else (authentication, business logic, UI, AI agents) sits outside the kernel as composable layers: keeping the kernel
small and the application surface open.
To prevent unbound flexibility from manifesting as system rigidity, the architecture draws a hard line between what is
standardized and what is delegated. It's:
- **Opinionated where needed**: one protocol envelope, Worker-declared capabilities, unidirectional flows
- **Flexible where it matters**: isolated Workers handle translation logic, enabling integrations without polluting the core
infrastructure
Five layers compose the stack, with strict, unidirectional flows between them. The kernel itself is **Kernel**, the
Orchestration Kernel, distributed as `@tetherto/mdk-kernel`.
## MDK stack
```mermaid
graph TB
subgraph consumers ["Layer 1: Consumers"]
UI["UI / Frontend"]
AI["AI Agent"]
end
subgraph gateway ["Layer 2: Gateway"]
WebApp["HTTP / API Router"]
MCPServer["MCP Server Endpoint"]
end
subgraph kernelLayer ["Layer 3: @tetherto/mdk-kernel"]
Kernel["Kernel • Command routing • Health monitoring • Device registry • Telemetry collection"]
end
subgraph workers ["Layer 4: Workers"]
Workers["WORKERS"]
end
subgraph devices ["Layer 5: Physical Devices"]
Devices["Physical devices • Miners • Containers • Sensors"]
end
UI -->|"HTTP (polling)"| WebApp
AI -->|"MCP Protocol"| MCPServer
WebApp -->|"MDK Protocol via @tetherto/mdk-client / HRPC"| Kernel
MCPServer -->|"MDK Protocol via @tetherto/mdk-client / HRPC"| Kernel
Workers -.->|"join known DHT topic"| Kernel
Kernel -->|"MDK Protocol: pull (identity / capabilities / telemetry) + command"| Workers
Workers -->|"device libs"| Devices
style consumers fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
style gateway fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
style kernelLayer fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
style workers fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
style devices fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
```
The MDK components that compose those layers:
| Component | What it does |
|---|---|
| [`@tetherto/mdk-kernel`](#the-kernel) | Central coordination: routes commands, collects telemetry, monitors health |
| [`@tetherto/mdk-client`](#the-sdk) | Universal SDK applications use to talk to `@tetherto/mdk-kernel` |
| [MDK Protocol](#the-mdk-protocol) | Standardized message envelope every layer speaks |
| [MDK App Toolkit](/concepts/stack/app-toolkit) | Optional frontend tools, backend tools, and plugins on top of `@tetherto/mdk-kernel` |
## Storage
[Hypercore](https://github.com/holepunchto/hypercore)-backed stores (such as
[Hyperbee](https://github.com/holepunchto/hyperbee)) are recommended across the `@tetherto/mdk-kernel`, Worker, and Gateway layers.
This choice satisfies all storage requirements without the operational baggage of a centralized database.
## The MDK protocol
The MDK protocol is the contract that crosses every layer of the stack. Workers become reachable — via a
[DHT topic](/concepts/stack/workers#microservices-mode) or [same-machine discovery](/concepts/stack/workers#local-mode), and `@tetherto/mdk-kernel`
initiates every RPC call. Workers issue no callbacks, emit no fan-out events, and make no exceptions to the direction of flow.
The protocol is the stable language of the stack: the same envelope and action catalogue whether Kernel talks to a Worker, a
Gateway plugin talks through `@tetherto/mdk-client`, or an agent reaches Kernel over MCP. New hardware joins by publishing a
contract, not by extending Kernel.
For the full [envelope shape](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/lib/protocol/envelope.js) and [action catalogue](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/lib/protocol/actions.js), see the [Protocol reference](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md).
### Design principles
- **Transport-agnostic**: identical messages whether routed [in-process](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/lib/transport/envelope-router.js), over [Hyperswarm RPC (HRPC)](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/lib/transport/hrpc-listener.js),
or via API calls
- **Strictly unidirectional**: [Workers](/concepts/stack/workers) never initiate RPC calls to `@tetherto/mdk-kernel`; `@tetherto/mdk-kernel`
discovers their presence and initiates all subsequent communication downwards (identity, capabilities, telemetry, commands)
- **Generic interface**: the accepted interface is defined at the Worker: [`mdk-contract.json`](/concepts/stack/workers#capability-contract) carries
both structure and semantic context for AI agents
### Discovery, telemetry, and command flows
```mermaid
sequenceDiagram
participant W as Worker
participant DHT as DHT Topic (Hyperswarm)
participant O as @tetherto/mdk-kernel
participant G as Gateway (HTTP / MCP)
Note over W,O: Worker discovery and registration
W->>DHT: Joins known topic
O-->>DHT: Detects new peer connection
O->>W: identity.request
W-->>O: identity.response (devices)
O->>O: Save Worker to registry
O->>W: capability.request
W-->>O: capability.response (schema)
Note over O,W: Telemetry pull loop
O->>W: telemetry.pull
W-->>O: metrics and pending commands
Note over G,W: Command execution
G->>O: MDK Protocol HRPC envelope
O->>W: command.request (routed by deviceId)
W-->>O: command.result
O-->>G: result
```
## The Kernel
[Kernel](/concepts/stack/kernel), [`@tetherto/mdk-kernel`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md), is the trusted coordination layer at the heart of MDK. It [routes commands](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#commanddispatcher),
[monitors device health](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#healthmonitor), [registers Workers](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#workerregistry), and [pulls telemetry](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#telemetrycollector) — all on a
[pull-only model](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#scheduler), so the kernel cannot be overwhelmed by upstream pressure.
When a command arrives, callers only need to provide a `deviceId`; `@tetherto/mdk-kernel` resolves the owning Worker internally via
the [`CommandDispatcher`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#commanddispatcher) and dispatches the `command.request`.
## Workers
[Workers](/concepts/stack/workers) wrap a device library and expose it via the MDK protocol. They are the integration handlers between physical hardware
and `@tetherto/mdk-kernel`, and the unyielding source of truth for that hardware: `@tetherto/mdk-kernel` itself operates purely as a synchronized state
machine over Worker-reported state.
Workers are passive — Kernel initiates every RPC call; Workers only ever respond. Kernel discovers Workers according to the
[discovery model](/concepts/stack/workers#discovery-model), then requests identity and capabilities.
## The SDK
The [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) SDK is the transport abstraction layer used to connect to `@tetherto/mdk-kernel` reliably.
It is the essential glue between the kernel and any consumer layer developers choose to build on top.
**Responsibility**: connects the MDK Protocol over the native HRPC transport seamlessly, offering:
- **Transport abstraction**: handles MDK Protocol message construction and reconnection logic with exponential backoff.
- **Key-based addressing**: the SDK connects over encrypted Hyperswarm streams, addressed by the kernel's HRPC public key.
The same transport serves remote server-to-server production and same-host development alike — for the local zero-config case,
the kernel publishes its key to a well-known key file that clients read at startup.
- **Major language support**: `@tetherto/mdk-client` is intended to support all major languages (Node.js, Python, Go, and others), allowing
developers to dispatch commands or pull status snapshots from any stack.
## Gateway
The [Gateway](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) is a container that hosts plugins and delivers an HTTP interface on top of Kernel: each plugin
builds its own `@tetherto/mdk-client` — the MDK protocol connector to Kernel — from its context. Consumers that need those
capabilities connect through the Gateway.
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.
The supported development path is the [MDK App Toolkit](/concepts/stack/app-toolkit), which ships a per-plugin context for Kernel access,
request-level caching, frontend tools, and an `mdk-plugin.json`-based plugin system for declarative HTTP route extensions
([plugin guide](/guides/gateway/plugins)). Authenticating callers is not part of that: it is work each plugin controller does,
using an identity layer you supply.
For the full developer model — extension patterns, data access, auth design, and Kernel connection — read the [Gateway concept page](/concepts/stack/gateway).
## AI agents and the MCP server
This section covers any external AI agent connecting to MDK as an MCP client. MDK's own shipped operator agent,
`@tetherto/mdk-agent`, is one such agent: it reaches fleet data through this same MCP path. Separately,
[`@tetherto/mdk-plugin-agent`](/guides/agent/gateway-deployment) mounts a chat API on the Gateway so a human operator can talk to that
agent, a different surface from the MCP endpoint described below.
The supported application path connects AI agents through the standalone MCP server's **MCP endpoint**, not the Gateway's HTTP
surface. Agents sit in the same security envelope as every other consumer of that server: whatever checks front it apply equally
to a human caller and an agent, and an unprotected endpoint is open to both. Establishing that envelope is your work, since
neither Kernel nor the MCP server performs user-level [authentication](/concepts/stack/gateway#authentication-design) on its own.
What makes the integration distinctive is **[runtime tool derivation](https://github.com/tetherto/mdk/blob/main/docs/reference/maintainers/agent-ready-sdk.md)**. The tools exposed to an agent (for example,
`get_device_telemetry` or `reboot_device`) are not hardcoded; they are parsed at runtime from each registered Worker's
[`mdk-contract.json`](/concepts/stack/workers#capability-contract). When a new device type joins the network, the agent gains
the ability to query and control it without any change to the MCP server's own code.
The MCP server's only built-in protection is its bind address: it listens on `127.0.0.1` and answers `POST /mcp`. Anything that can reach that
port can drive the fleet, so an agent's tool calls carry whatever authority the loopback interface grants. Exposing the port beyond localhost
means putting your own authentication in front of it.
## End-to-end data flows
Two scenarios show the full request path from consumer to device and back: a [human user clicking through the UI](#human-ui-scenario), and an [AI
agent executing a multi-step prompt](#ai-agent-scenario).
### AI agent scenario
A user instructs the AI Agent: *"Keep the fleet healthy."* The agent monitors continuously, catches `wm002` overheating, reboots it, and notifies the user.
```mermaid
sequenceDiagram
actor User
participant AI as AI Agent
participant Node as Gateway (MCP)
participant Kernel as @tetherto/mdk-kernel
participant Worker as Generic Worker
User->>AI: "Keep the fleet healthy."
Note over AI,Kernel: Step 1: Fleet discovery (read)
AI->>Node: Call MCP tool get_fleet_alerts
Node->>Node: Your validation, if the tool handler implements any
Node->>Kernel: HRPC query (via @tetherto/mdk-client)
Kernel-->>Node: Metrics
Node-->>AI: Tool result (wm002 is overheating)
Note over AI,Kernel: Step 2: Execution (write)
AI->>Node: Call MCP tool reboot_device (deviceId wm002)
Node->>Node: Your validation, if the tool handler implements any
Node->>Kernel: dispatch generic protocol message
Kernel->>Kernel: Resolve deviceId
Kernel->>Worker: command.request (HRPC)
Worker-->>Kernel: command.result
Kernel-->>Node: result OK
Node-->>AI: Tool result (Success)
AI-->>User: "wm002 was overheating and has been rebooted."
```
### Human UI scenario
A user clicks "Reboot" on device `wm001` in the UI.
```mermaid
sequenceDiagram
actor User
participant UI as React UI
participant Node as Gateway
participant Kernel as @tetherto/mdk-kernel
participant Worker as Generic Worker
User->>UI: Click "Reboot" on wm001
UI->>Node: POST { `deviceId`, action, payload }
Note over Node,Kernel: Delegation
Node->>Kernel: dispatch generic protocol message
Kernel->>Kernel: Verify against capabilities
Kernel->>Kernel: Resolve Worker for `deviceId`
Note over Kernel,Worker: Execution
Kernel->>Worker: command.request (HRPC)
Worker-->>Kernel: Ack start
Worker->>Worker: Hardware-specific translation
Worker-->>Kernel: command.result
Kernel-->>Node: result OK
Node-->>UI: HTTP 200
Note over Worker,Kernel: State reflection
Kernel->>Worker: telemetry.pull (tick)
Worker-->>Kernel: Updated status (rebooting)
```
The Gateway, Kernel, and Workers, [control plane includes approval-gated writes](/concepts/control-plane).
## Scaling
As MDK deployments scale to large mining sites (5,000+ devices), the system must explicitly manage parallel Workers and parallel
`@tetherto/mdk-kernel` instances. The kernel is only an execution layer; it does not perform application-level aggregation or
cross-regional business logic.
Scaling here means *how many* Workers and kernels you run. That is independent of [deployment topology](/concepts/deployment-topologies),
*how those processes are packaged* on a host (one process vs. many).
### Parallel Workers
Multiple Workers of the same type (for example, `whatsminer-worker`) can be active concurrently and connected to the same
`@tetherto/mdk-kernel` kernel.
```mermaid
flowchart TD
subgraph kernel ["Single @tetherto/mdk-kernel instance"]
Kernel["Kernel"]
end
W1["Worker 1"]
W2["Worker 2"]
D1["Devices wm001 to wm500"]
D2["Devices wm501 to wm999"]
Kernel -->|Routes commands| W1
Kernel -->|Routes commands| W2
W1 --- D1
W2 --- D2
style kernel fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
```
**Device-level routing and ownership**: Workers never share devices. When a Worker connects, its `identity.register` payload
explicitly lists the `deviceId`s it exclusively manages. The Worker registry maintains this strict mapping and deterministically
routes arriving commands to the designated Worker.
### Multi-site deployments
A deployment may need to manage multiple massive physical boundaries (for example, a Texas Site and an Iceland Site). Each
location runs its own dedicated site-level `@tetherto/mdk-kernel` kernel, but all are overseen globally by a single Gateway and AI Agent.
```mermaid
flowchart TD
Global["Global Gateway / AI Agent"]
subgraph texas ["Texas site"]
KERNEL_TX["@tetherto/mdk-kernel"]
W1_TX["Whatsminer Worker"]
W2_TX["Antminer Worker"]
D1_TX["Whatsminers"]
D2_TX["Antminers"]
KERNEL_TX -->|Routes| W1_TX
KERNEL_TX -->|Routes| W2_TX
W1_TX --- D1_TX
W2_TX --- D2_TX
end
subgraph iceland ["Iceland site"]
KERNEL_IC["@tetherto/mdk-kernel"]
W1_IC["Whatsminer Worker"]
W2_IC["Avalon Worker"]
D1_IC["Whatsminers"]
D2_IC["Avalons"]
KERNEL_IC -->|Routes| W1_IC
KERNEL_IC -->|Routes| W2_IC
W1_IC --- D1_IC
W2_IC --- D2_IC
end
Global <-->|MDK Protocol via HRPC| KERNEL_TX
Global <-->|MDK Protocol via HRPC| KERNEL_IC
style texas fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
style iceland fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
```
The single Gateway and AI Agent connect globally to all distributed `@tetherto/mdk-kernel` kernels via the native HRPC mesh (Hyperswarm).
Parallel `@tetherto/mdk-kernel` instances remain entirely isolated from one another: they do not federate registries, share queues, or
synchronize state. A crash at one site has zero impact on any other.
Cross-site aggregation is handled purely at the Gateway layer, where routes query multiple Workers via `@tetherto/mdk-kernel` and merge
the responses before returning them to the UI or Agent.
## Next steps
- Understand the [Kernel](/concepts/stack/kernel) — what it owns, the pull-only model, and transports
- Understand the [Gateway](/concepts/stack/gateway) — auth design, plugins, and Kernel connection
- Understand [Workers](/concepts/stack/workers) — discovery model, capability contract, and adding hardware
- Understand the [control plane](/concepts/control-plane) — how Gateway, Kernel, and Workers communicate and which layer owns each responsibility
- Choose a [deployment topology](/concepts/deployment-topologies) — single-process, local, or distributed
## Next steps
Learn more about:
- [About MDK](/concepts)
- [MDK App Toolkit](/concepts/stack/app-toolkit)
- [Kernel](/concepts/stack/kernel)
- [Try the demo](/tutorials/run-a-site)
# Control plane (/concepts/control-plane)
## Overview
This page covers authenticated requests, live reads, command dispatch, and approval-gated writes. It spans
the Gateway, Kernel, and Workers, but each layer owns a different responsibility.
Use this page to understand which layer receives a request, which layer validates it, and when a write becomes a command.
For package-level APIs and configuration, use the [Gateway README](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md), [Kernel README](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md), and [Worker README](https://github.com/tetherto/mdk/blob/main/backend/workers/README.md).
## Responsibility boundaries
**Gateway** owns the consumer-facing surface, including HTTP and plugins. It is also where authentication belongs, though it implements none:
that logic lives in the plugin controllers you write. Browser UIs and agents should enter MDK through the Gateway (they do not talk to Kernel
directly). Agents can also reach MDK over MCP through the standalone [`@tetherto/mdk-mcp`](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) package.
**Kernel** owns coordination: Worker registry, telemetry routing, health checks, command dispatch, command state, and the
write-action approval modules. Kernel trusts established callers; it does not validate user identity.
**Workers** own hardware integration. They declare capabilities, answer Kernel-initiated telemetry and state pulls, resolve candidate
write calls for approval-gated actions, and execute final commands against devices.
## Connection direction
The direction of each connection is intentional:
- Consumers call the Gateway over HTTP or MCP
- The Gateway dials Kernel over [Hyperswarm RPC (HRPC)](/reference/glossary#hyperswarm-rpc) through `@tetherto/mdk-client`
- Kernel discovers Workers, then initiates every Worker RPC
- Workers never initiate upstream calls to Kernel or the Gateway
The [deployment topologies](/concepts/deployment-topologies) and [Workers discovery model](/concepts/stack/workers#discovery-model) pages cover how this changes
across single-process, local, and distributed deployments.
## Transport identity and admission
HRPC uses encrypted Noise connections with public-key identities. Kernel's HRPC public key identifies and addresses the
Kernel listener. Each caller has a separate public key that the listener receives during connection setup.
Kernel compares the caller's key with the allowlist. An empty allowlist admits any HRPC caller; a configured allowlist
admits the approved callers. This transport-level check works the same way whether the processes share a host or
when they communicate across a network.
Transport identity is not user identity. The HRPC allowlist controls which backend processes may connect to Kernel, and it says nothing about the
person or agent behind a request. Establishing that is the job of the plugin controllers serving people, browser applications, and agents.
## Request paths
### Read requests
Reads usually start in a Gateway route or plugin controller, pass through the plugin's own `mdkClient`, and reach Kernel as registry,
capability, telemetry, or state queries. Kernel routes Worker-owned reads down to the relevant Worker and returns the result to the
Gateway. There is no separate Gateway-side store: a controller that wants a historical or aggregated series fans that same
`mdkClient` out across every registered Worker and reads it from the Worker's own persisted tail-log.
For plugin controller mechanics, use the [Gateway plugins guide](/guides/gateway/plugins).
### Direct commands
Direct commands are immediate writes that do not require approval. The plugin controller performs whatever validation you have written into it, then
sends a `command.request` to Kernel. Kernel resolves the owning Worker, validates the command against the Worker's capabilities,
and hands the command to the crash-recoverable command state machine.
For command-dispatch module details, use the [Kernel README](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md).
### Approval-gated writes
Some writes are staged for approval before they become commands. This keeps direct commands available while adding a separate
review path for fleet-changing actions that need operator approval.
```mermaid
flowchart TB
directCommand["Direct command"] --> commandRequest["command.request"]
commandRequest --> dispatcher["CommandDispatcher"]
dispatcher --> stateMachine["CommandStateMachine"]
stateMachine --> worker["Worker write"]
writeAction["Approval-gated write action"] --> actionPush["action.push"]
actionPush --> actionManager["ActionManager"]
actionManager --> actionApprover["ActionApprover / voting store"]
actionApprover --> approved{"Approved?"}
approved -->|"yes"| actionCaller["ActionCaller"]
approved -->|"no"| stopped["Rejected or cancelled"]
actionCaller --> commandRequest
```
The Gateway may expose an HTTP actions surface through plugins. Any access control on that surface is written into the controllers, since the plugin
runtime enforces none. Kernel owns `ActionManager`, `ActionCaller`, and target permission checks at the protocol layer. Those Kernel checks use the
target Worker's device family, such as `miner:w` or `container:w`, read from the `authPerms` array the caller sends, before resolving or approving
writes. Workers answer
`write.calls.request` while Kernel resolves candidate writes, then execute the final `command.request` after the configured vote
thresholds are met.
For implementation steps, use the [write-actions how-to](/guides/gateway/write-actions). For React hook names and exports, use the [React adapter README](https://github.com/tetherto/mdk/blob/main/ui/packages/react-adapter/README.md).
## Developer surfaces
The write-action flow is reachable from two different layers depending on where you are building.
| Layer | Package | How you call it |
|---|---|---|
| React / UI | [`@tetherto/mdk-react-adapter`](https://github.com/tetherto/mdk/blob/main/ui/packages/react-adapter/README.md) | Six hooks: `useSubmitSingleAction`, `useSubmitPendingActions`, `useVoteOnAction`, `useCancelAction`, `usePendingActions`, `useLiveActions` — call Gateway HTTP routes (plugin-provided) |
| Backend / Node.js | [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) | Methods: `pushAction`, `pushActionsBatch`, `voteAction`, `cancelActionsBatch`, `getAction`, `getActionsBatch`, `queryActions` — send MDK Protocol envelopes directly to Kernel |
The React hooks go through the Gateway, so whatever validation your plugin controllers perform applies to them. The `mdk-client` methods connect
directly to Kernel and bypass that layer entirely. Neither path gets user-level control for free: the Gateway ships no authentication, and Kernel
admits backend processes according to its HRPC transport policy, where an empty allowlist admits any caller and a configured allowlist admits
matching caller keys.
## Next steps
- Build Gateway routes with the [plugin guide](/guides/gateway/plugins)
- Submit and approve write actions with the [write-actions how-to](/guides/gateway/write-actions)
- Review the [Kernel modules](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md)
- Review Worker capabilities in the [Worker README](https://github.com/tetherto/mdk/blob/main/backend/workers/README.md)
## Next steps
- [Try the demo](/tutorials/run-a-site)
Learn more about:
- [About MDK](/concepts)
- [MDK App Toolkit](/concepts/stack/app-toolkit)
- [Kernel](/concepts/stack/kernel)
# Deployment topologies (/concepts/deployment-topologies)
This page explains the three supported deployment shapes and when to pick each.
## Overview
MDK's runtime pieces — the [Kernel](/concepts/architecture), the Gateway, and one or more Workers — can run together
in a single process or be split across several. This is a **packaging and operations** choice, and it's
independent of how MDK [scales logically](/concepts/architecture#scaling) (adding Workers, adding sites).
If Kernel, Worker, manager, or thing are unfamiliar, read the [`glossary.md`](/reference/glossary) first.
## Connection model
Before choosing a shape, it helps to understand which components initiate connections:
- The Gateway dials Kernel — it is the active side of that connection, over [Hyperswarm RPC (HRPC)](/reference/glossary#hyperswarm-rpc) using the Kernel's public key (read from the well-known key file on the same host, or passed as `kernelKey` for a remote host)
- Kernel discovers Workers and initiates every RPC call — Workers are passive; they become reachable and wait
- Workers never initiate any connection
This directionality is what drives the transport and discovery configuration in each shape below.
For detail, see the [Workers discovery model](/concepts/architecture#workers) and the [Gateway Kernel connection](/concepts/stack/gateway#kernel-connection).
## The three shapes
### Single process
```mermaid
flowchart LR
classDef mdk fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
sApp["Gateway"]:::mdk -->|"HRPC"| sKernel["Kernel"]:::mdk
sKernel -.->|"in-process"| sW1["Worker A"]:::mdk
sKernel -.->|"in-process"| sW2["Worker B"]:::mdk
```
*Solid arrow: active connection initiated by the source. Dashed arrow — Kernel-initiated discovery.*
Kernel, the Gateway, and every Worker run inside one Node.js heap and event loop. Lowest footprint, simplest to start, nothing external to supervise. This is the shape behind the [single-process site how-to](/guides/deployment/run-single-process-site).
### Local
```mermaid
flowchart LR
classDef mdk fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
lApp["Gateway"]:::mdk -->|"HRPC"| lKernel["Kernel"]:::mdk
lKernel -.->|"shared dir"| lW1["Worker A"]:::mdk
lKernel -.->|"shared dir"| lW2["Worker B"]:::mdk
```
*Solid arrow: active connection initiated by the source. Dashed arrow — Kernel-initiated discovery.*
Each service runs as its own OS process on the same machine. Kernel discovers Workers via a shared directory — no DHT configuration needed. The [supervised-services site guide](/guides/deployment/run-all-workers-site) demonstrates this as its default mode.
### Microservices
```mermaid
flowchart LR
classDef mdk fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
mApp["Gateway (host 1)"]:::mdk -->|"HRPC"| mKernel["Kernel (host 2)"]:::mdk
mKernel -.->|"DHT"| mW1["Worker A (host 3)"]:::mdk
mKernel -.->|"DHT"| mW2["Worker B (host N)"]:::mdk
```
*Solid arrow: active connection initiated by the source. Dashed arrow — Kernel-initiated discovery.*
Each service runs as its own OS process or container, potentially on separate hosts, supervised by pm2 or Docker and connected via DHT. The same guide's example switches to this shape by setting its `discovery` config field to `"dht"`.
## The trade-off
Pick **single-process** when:
- You are developing locally, running demos, or want a self-contained site for tests
- Footprint matters more than isolation (minimal or embedded deployments)
- You do not need supervisor-managed restarts
Pick **local** when:
- All services run on one machine and you want independent process restarts
- Outbound networking is restricted removing DHT as an option
- You want process isolation and independent restarts without the complexity of DHT
Pick **microservices** when:
- You want to allocate resources per service — CPU and memory limits per process or container
- Workers run on separate hosts from Kernel or the Gateway
- You are orchestrating many Workers across one or more hosts
## Where [`worker.js`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/worker.js) fits
The microservices shape is built on [`backend/core/mdk/worker.js`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/worker.js), a shared process entry compatible with pm2, Docker, or a direct `node worker.js`. It is driven by environment variables (`SERVICE`, and for a Worker `WORKER`/`TYPE`/`RACK`) rather than CLI flags. One [`worker.js`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/worker.js) runs per service, and the supervisor (pm2 or Docker) owns its lifecycle and resource limits. The [standalone `worker.js` install pattern](https://github.com/tetherto/mdk/blob/main/backend/workers/docs/install-pattern.md#standalone-via-workerjs) defines the per-Worker mechanics.
The single-process and local shapes both call the programmatic APIs directly: `getKernel()` and `startGateway()` from [`@tetherto/mdk`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md),
and each Worker's own boot function (there is no single generic `startWorker`). Local mode passes `discovery: { mode: 'local' }` to `getKernel()` and
publishes each Worker's RPC key to the same shared directory with `publishWorkerKey()`. The [local Worker discovery](/concepts/stack/workers#local-mode) page has
the configuration options.
## Relationship to scaling
Topology is orthogonal to scale. [Logical scaling](/concepts/architecture#scaling) is about *how many* Workers and Kernel kernels you run (parallel Workers, per-site kernels, multi-site oversight). Deployment topology is about *how those processes are packaged* on a given host. You choose both: for example, a production site typically runs multiple processes (this page) and multiple parallel Workers per kernel ([scaling](/concepts/architecture#scaling)).
## Next steps
- Run a self-contained local site: [Single-process site](/guides/deployment/run-single-process-site)
- Run [same-machine services without DHT](/concepts/stack/workers#local-mode)
- Run [a multi-Worker site as supervised services, from one machine up to a cross-host deployment](/guides/deployment/run-all-workers-site)
- Register [one miner before packaging a whole site](/guides/miners)
# Security boundaries (/concepts/security-boundaries)
🚧 This page is under construction: more data to follow.
MDK enforces no user identity at any tier. The Gateway serves its plugin routes to any caller, Kernel inspects no user identity, and
`WorkerRuntime` applies no caller allowlist. Wherever this page says consumers enter through the Gateway, that path carries only the
authentication you build into your plugin controllers. Until you build it, network policy and process isolation are the only boundaries
protecting the fleet.
## Worker security boundary
`WorkerRuntime` listens over HyperswarmRPC. Its underlying HyperDHT connection uses encrypted Noise transport, and the
Worker's HRPC public key identifies and addresses that Worker endpoint. This authenticates the endpoint to the
connecting backend peer; it does **not** establish a human or application identity, grant command permission, or
substitute for the application-level authentication you implement above it.
The current `WorkerRuntime` does not enforce a caller allowlist before dispatching supported envelopes. Any backend
peer that can reach the Worker and address its public key may send requests. Kernel's HRPC caller allowlist protects
clients connecting to Kernel; it does not authorize direct callers to a Worker endpoint. Consumers must enter through
the Gateway → Kernel path, with request authentication implemented in the Gateway's plugin controllers, and direct
Worker reachability must be restricted to trusted backend networks. Treat Worker public keys and DHT topics as
deployment configuration, distribute them through an authenticated control plane, apply host/container firewall
policy, and never expose device management interfaces publicly. DHT topics provide rendezvous only; they are not credentials or authorization tokens.
The minimal host passes `services: null`. It therefore does not provide first-party service built-ins or
`write.calls.request` approval integration — see
[Worker Runtime legacy services](https://github.com/tetherto/mdk/blob/main/docs/reference/maintainers/worker-runtime-legacy-services.md) for the full built-in
surface an `opts.services` object can activate. Direct `command.request` dispatch still reaches plugin command handlers.
Production command paths must authenticate the requester at the Gateway/control plane, authorize each device and
command, optionally require approval for high-impact actions, validate again in the handler, rate-limit, and create
an audit record containing actor, target, requested parameters, outcome, and correlation ID. The handler context does
not currently include actor identity, so actor-level auditing belongs upstream; handler logs supplement it. See the
[control-plane security model](/concepts/control-plane) for the production trust path.
Inject credentials through the host process from a secret manager or protected environment, pass only the minimum
device-specific values in `config`, never place secrets in `mdk-contract.json`, and redact credentials and device
responses from errors, debug logs, telemetry, and audit records.
# Stack (/concepts/stack)
MDK's backend is composed of four coordinated layers. Each layer has a single, bounded responsibility; together they form a complete path from physical device to application consumer.
| Layer | What it owns |
| --- | --- |
| [Workers](/concepts/stack/workers) | Device integration — translate hardware telemetry and commands into the MDK Protocol |
| [Kernel](/concepts/stack/kernel) | Kernel — route commands, monitor health, register Workers, and pull telemetry |
| [Gateway](/concepts/stack/gateway) | Application gateway — authenticated HTTP, WebSocket, and MCP interface on top of Kernel |
| [MDK App Toolkit](/concepts/stack/app-toolkit) | Development toolkit — Gateway backend, plugin system, and frontend packages |
For the architecture overview and how data flows between layers, see [Architecture](/concepts/architecture).
# Agent (/concepts/stack/agent)
## Overview
This page introduces the operator agent, `@tetherto/mdk-agent`, as a stack component: what it owns, how
it reaches fleet data, and the two ways to run it. Read this before choosing [how to deploy it](/guides/agent).
## What the agent is
A conversational operator interface: it answers plain-language questions about the fleet, calls fleet tools over MCP, and
gates every write behind human approval. A local model routes and narrates; the tools compute, and the agent never invents
fleet data.
## How it fits the stack
Like [any AI agent](/concepts/architecture#ai-agents-and-the-mcp-server), it reaches fleet data through the standalone [`@tetherto/mdk-mcp`](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) server,
the same MCP endpoint every other agent uses, not a special path of its own. What makes it distinct from a generic AI agent
connecting to that server is that it ships as a complete, opinionated package on top: a tool-authoring contract, a pluggable
session store, and an eval battery that scores routing, the answer, the result contract, and the approval gate against a live
fleet.
## Two ways to run it
- **Standalone CLI**: a small library and CLI for local development and evaluation. One conversation lives in a process
variable and exits with it; no Gateway, no auth, no multi-user session store.
- **Behind the Gateway**: `@tetherto/mdk-plugin-agent` mounts the same library as a chat API,
sessions, SSE message streams, and approval round-trips, so multiple operators hold independent conversations through one
running Gateway.
The Gateway plugin is a deployment mode, not a different product: enabling it brings the same session, tool, and approval
loop the CLI runs, with a `SessionStore` behind it instead of one variable in a process.
## Key packages
| Package | What it is |
| --- | --- |
| `@tetherto/mdk-agent` | The library and CLI: session, tool loop, session store, eval battery |
| `@tetherto/mdk-plugin-agent` | The Gateway plugin that mounts it as a chat API |
## Next steps
- [Choose a guide](/guides/agent) to run the agent standalone or behind the Gateway
- Understand [AI agents and the MCP server](/concepts/architecture#ai-agents-and-the-mcp-server), the mechanism this agent is one instance of
- [Understand the Gateway as a development surface](/concepts/stack/gateway), if deploying behind it
# MDK App Toolkit (/concepts/stack/app-toolkit)
## Overview
The MDK App Toolkit is the recommended development path for teams building MDK-powered applications. It is composed of
three coordinated layers:
- Gateway backend
- Plugin system
- Frontend packages
Not every layer is required for every consumer type.
MDK supports two primary consumer patterns:
- **Human operator UI**: a frontend application connects to the Gateway's [REST](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md#http-api-overview) API and polls it for
[live data](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md#live-data). The full three-layer toolkit applies — Gateway, plugin system, and frontend packages
- **AI agent / headless consumer**: an AI agent connects via the standalone [`@tetherto/mdk-mcp`](https://github.com/tetherto/mdk/blob/main/backend/core/mcp/README.md) package. The frontend packages are not required
## Gateway layer
`@tetherto/mdk-gateway` is the backend component of the toolkit: a container that hosts plugins and delivers an HTTP interface for
consumers that need those capabilities. Each plugin builds its own [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) — the Kernel protocol
connector — from its context.
Read the [Gateway concept page](/concepts/stack/gateway) for the full developer model: extension patterns, data access, auth design, and Kernel connection.
As a toolkit component, the Gateway provides out of the box:
- Fastify-based HTTP server
- Declarative plugin loading, request-level caching, and manifest validation at startup
- A per-plugin context each plugin uses to build its own `@tetherto/mdk-client` for command dispatch and telemetry access
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.
Authentication, session management, and RBAC are not included. [Identity is yours to supply](/guides/gateway/plugins#auth-and-permissions), invoked from the controllers that
need it.
Using [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) without the Gateway runtime is technically possible — you write your own auth,
routing, and middleware — but it is not supported by this monorepo which opinions that applications build on the Gateway.
## Plugin system
`@tetherto/mdk-plugins` is the extension mechanism. A 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. The Gateway discovers and loads plugins from directories passed via [`extraPluginDirs`](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md#mounting-plugins).
The toolkit auto-loads several [plugins](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md) and ships `auth` alongside, allowing you to
[provide your identity solution](/guides/gateway/plugins#auth-and-permissions). Any [plugin you write](/guides/gateway/plugins) loads by the same mechanism.
## Frontend packages
These packages are for the **human operator UI** pattern — the application layer that connects to the Gateway's REST API and
polls it for live data. If your consumer is an AI agent connecting via MCP, this layer is not required.
Consuming applications add the workspace dependencies directly. Consuming the whole chain is the recommended path for operator UIs.
The [UI architecture reference](https://github.com/tetherto/mdk/blob/main/ui/docs/ARCHITECTURE.md) covers the full dependency graph, build strategy, and package internals.
**[`@tetherto/mdk-ui-foundation`](https://github.com/tetherto/mdk/blob/main/ui/packages/ui-foundation/README.md)**: framework-agnostic headless core. No React imports. Provides Zustand vanilla stores
(`authStore`, `devicesStore`, `notificationStore`, `timezoneStore`, `actionsStore`), a TanStack `QueryClient` factory with
environment-aware base URL resolution, centralised `queryKeys` and query factories for all read endpoints (including Op Centre reads —
site, racks, PDU layout, global data, `thingConfig` — and Pool Manager), Op Centre query parameter builders, the per-model container
detail-tab matrix, a null-safe envelope flattener (`flattenKernelEnvelope`), and the Gateway API type contracts.
**[`@tetherto/mdk-react-adapter`](https://github.com/tetherto/mdk/blob/main/ui/packages/react-adapter/README.md)**: React bindings for the core. Provides ``
(required at the app root) and store hooks (`useAuth`, `useDevices`, `useTimezone`, `useNotifications`, `useActions`).
**[`@tetherto/mdk-react-devkit`](https://github.com/tetherto/mdk/blob/main/ui/packages/react-devkit/README.md)**: React UI library. `src/primitives/` ships generic UI primitives built on Radix UI
(Button, Dialog, Switch, Select, Data Table, Charts). `src/domain/` ships mining-domain components, features, and presentation hooks.
### Developer entry points
The toolkit can be adopted at any of the following entry points, from most batteries-included to least.
| Entry point | Package | What ships | What you write | When to choose |
|---|---|---|---|---|
| UI Kit | `@tetherto/mdk-react-devkit` (`/primitives` + `/domain` entrypoints) | Pre-built React components, shell layout, ready-made ops dashboard | Data wiring, optional theming | You want a dashboard up fast |
| Framework adapter | `@tetherto/mdk-react-adapter` (React today; Vue/Svelte/WC planned) | ``, store hooks, TanStack Query re-exports | Your own components and layout | You have a design system already |
| UI Foundation | [`@tetherto/mdk-ui-foundation`](/reference/ui) | Zustand vanilla stores, `QueryClient` factory, `queryKeys`, query factories, Op Centre query builders, container tab matrix, API types | Framework bindings or headless utilities | You need store access outside React or are building a new adapter |
| Raw SDK | `@tetherto/mdk-client` | MDK Protocol client, connection management, reconnection | Everything above the wire: state, framework, UI | You are building a non-UI consumer (CLI, agent, backend service) |
## Architecture overview
```mermaid
flowchart TD
subgraph frontend ["Frontend packages"]
direction TB
UI_FOUNDATION["@tetherto/mdk-ui-foundation (headless stores)"]
FRAMEWORKS["@tetherto/mdk-react-adapter (React bindings)"]
UI_COMPS["@tetherto/mdk-react-devkit (UI Kit)"]
UI_COMPS -->|consumes adapter hooks| FRAMEWORKS
FRAMEWORKS -->|binds headless stores| UI_FOUNDATION
end
subgraph backend ["Gateway + plugins (server)"]
direction TB
PLUGINS["@tetherto/mdk-plugins (default + custom routes)"]
ROUTER["@tetherto/mdk-gateway (HTTP / MCP)"]
CLIENT["@tetherto/mdk-client (protocol connector)"]
PLUGINS -->|registers routes into| ROUTER
ROUTER -->|proxies to Kernel via| CLIENT
end
UI_FOUNDATION -->|"HTTP (polling)"| ROUTER
CLIENT -->|"MDK Protocol"| Kernel["@tetherto/mdk-kernel (kernel)"]
style frontend fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
style backend fill:#F7931A,stroke:#1A1A1A,color:#1A1A1A
```
## Next steps
- Understand the [Gateway surface](/concepts/stack/gateway)
- [Build or extend with the plugin system](/guides/gateway/plugins)
- Explore the [frontend package architecture](https://github.com/tetherto/mdk/blob/main/ui/docs/ARCHITECTURE.md)
# Gateway (/concepts/stack/gateway)
## Overview
This page introduces the [Gateway](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md) surface. It explains what concerns it owns, how to extend it with plugins and routes,
how data flows from Kernel to your controllers, and why authentication lives here rather than in the kernel.
Read this before building [plugins](/guides/gateway/plugins), auth flows, or aggregation routes on top of MDK.
The Gateway is the backend layer of the [MDK App Toolkit](/concepts/stack/app-toolkit), which aligns the [plugin system](/guides/gateway/plugins)
and [frontend packages](/concepts/stack/app-toolkit) into the supported development path for this monorepo.
## What the Gateway owns
The Gateway is a container that hosts plugins and adds an HTTP interface on top of Kernel: each plugin builds its own
[`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) — the MDK protocol connector to Kernel — from its context. Consumers connect through the
Gateway's plugin routes.
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.
The Gateway owns concerns that Kernel deliberately **does not** handle:
- The place where authentication belongs: user identity is a Gateway-tier concern: validating callers falls to your plugin controllers and the identity layer you supply
- API surface: REST endpoints and command dispatch
- Fleet aggregation: cross-Worker queries that compute site hashrate, average temperature, and cross-rack efficiency — resolved in controller code, not in Kernel
The [Kernel](/concepts/stack/kernel) is a pass-through, routing commands to [Workers](/concepts/architecture#workers), collecting telemetry, and maintaining
the device registry. Everything above the kernel — authentication, business logic, API surface — is owned by the caller: each
plugin, through its own [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md), when using the toolkit's Gateway.
## Extension model
The Gateway offers two ways to add routes, in order of preference.
### 1. Plugin system
The recommended path. A plugin is a directory with an `mdk-plugin.json` manifest and one or more controller files.
Pass the directory path to `startGateway()` via `extraPluginDirs`.
A controller receives just `(req)`. Each plugin reads its own config from
`require('@tetherto/mdk-gateway/plugin')` and builds its own [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) from it, so protocol knowledge stays
inside the client, not the controller. The [default plugins](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md#default-plugins) (`telemetry`, `site-hashrate`, `site-monitor`) load
automatically this way. An `auth` plugin ships beside them but the Gateway neither registers it nor gives its controllers what they
still expect (a second handler parameter carrying an identity layer), so mounting it does not yield working identity endpoints.
The [plugin authoring guide](/guides/gateway/plugins) covers the build process end to end.
The [plugin reference](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/README.md) documents the manifest schema, controller contract, and loader errors.
### 2. Raw Fastify routes
For one-off handlers that do not need a manifest, pass `additionalRoutes` to `startGateway()`. These are plain Fastify route objects —
no plugin context, no manifest validation, no auth wiring. Use this path sparingly; a plugin is easier to test in isolation
and easier for a later maintainer to follow.
## Connect without the Gateway
If your use case does not need the Gateway's HTTP surface or plugin system, for example a background service that only
dispatches commands — you can use [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) directly against Kernel without running the Gateway at all.
This is the direct path. Such an approach is not directly supported by this monorepo, as most applications build on the Gateway.
## Data access
There is one data source inside a plugin controller: the [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) the plugin built for itself from its
context config. Live reads (`pullTelemetry`, `sendCommand`, `listWorkers`) go straight through it. There is no separate Gateway-side
store for historical or aggregated data — a plugin that needs a time series fans the same client's `pullWorkerTelemetry` out across
every registered Worker and reads it from the Worker's own persisted tail-log; [`telemetry/lib/site-data.js`](https://github.com/tetherto/mdk/blob/main/backend/core/plugins/telemetry/lib/site-data.js)
shows the pattern the bundled `telemetry` plugin uses. Both "live" and "historical" reads are therefore network calls through the
client and can fail if the Worker (or Kernel, for `listWorkers`/`getStatus`) is unreachable — guard both the same way, and map a
failure to your own error rather than assuming one path degrades gracefully and the other doesn't.
## Authentication design
Neither tier authenticates a user. The Gateway serves whatever routes its plugins declare, to any caller, and Kernel does no user-level
authentication by design. The HRPC connection is an encrypted Noise channel, and [Kernel maintains an allowlist](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#transports); pre v1.0 it is
opt-in (the default `auth.whitelist` is empty and admits any caller), but when configured the Gateway's DHT public key must be added before the
connection is accepted. Once the transport is established, Kernel trusts all messages from the Gateway without inspecting user identity.
User authentication and RBAC belong to the application **you build** on the Gateway. A route is reachable by anyone unless its controller
validates the token and checks permissions itself, so [protecting a route is controller work](/guides/gateway/plugins#auth-and-permissions). The `"auth"` and `"permissions"` fields
in `mdk-plugin.json` have no reader and trigger no enforcement.
Kernel does check one thing on the write path: `ActionManager` and `ActionCaller` require the device-family write permission (`miner:w`,
`container:w`) in the `authPerms` array your controller passes with each action, and reject the action with `ERR_ACTION_DENIED` without it.
## Kernel connection
The Gateway is the **active** side of this connection — it dials Kernel. [Kernel](/concepts/stack/kernel) is the passive listener; it does not
initiate contact with the Gateway.
The connection is [Hyperswarm RPC (HRPC)](/reference/glossary#hyperswarm-rpc) — an encrypted peer-to-peer transport addressed by Kernel's public key. What varies is how
the Gateway obtains that key:
- **Same host (zero-config default)**: Kernel publishes its HRPC public key to a well-known key file (`/mdk/.kernel-key`)
on start; the Gateway reads it from there automatically when no key is passed
- **Separate hosts**: pass the key explicitly (`startGateway({ kernelKey })`), obtained from `kernel.getPublicKey()` on the Kernel
host. When [Kernel's `auth.whitelist`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#transports) is configured, the Gateway's DHT public key must be added to it before
the connection is accepted
Pre v1.0, the allowlist is opt-in. Kernel's `auth.whitelist` defaults to empty, which admits any HRPC caller. When an allowlist
is configured, the Gateway's DHT public key must appear in it before Kernel accepts the connection.
## Next steps
- [Run the Gateway for the first time](/guides/gateway/run)
- [Add routes with the plugin system](/guides/gateway/plugins)
- Review the [full API and configuration reference](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md)
- Choose a [deployment shape](/concepts/deployment-topologies)
# Kernel (/concepts/stack/kernel)
## Overview
[`@tetherto/mdk-kernel`](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md) is the trusted coordination kernel at the heart of MDK. It routes commands, monitors device
health, registers Workers, and pulls telemetry — without performing user authentication, business logic, or aggregation.
Kernel is a pass-through kernel: it receives commands from any caller using [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) — most commonly
the [Gateway](/concepts/stack/gateway) — and dispatches them to [Workers](/concepts/stack/workers); it pulls telemetry from Workers and routes
it back to callers. Everything else is the caller's responsibility.
## What Kernel owns
Kernel is decomposed into six single-responsibility modules. Modules communicate only through their declared interfaces.
**`WorkerRegistry`**: maps `deviceId → workerId → RPC channel`. Source of truth for Worker-to-device routing. Workers progress
through a state machine as Kernel discovers and registers them — Unregistered → Discovered → IdentitySaved → Ready → Terminated.
**`CommandDispatcher`**: admits a `command.request`, resolves the owning Worker from the registry, checks the command against
that Worker's declared capabilities, then passes it to the state machine. Scope resolution (`COMMAND_SCOPES`:
`device` | `worker` | `rack`) expands a single command to one or more target devices; a `MAX_TARGETS` cap (1024) is enforced
before any state is written.
**`CommandStateMachine`**: tracks every command's full execution lifecycle. Backed by a Write-Ahead Log (WAL) in Hyperbee —
every state transition is persisted before it takes effect. On restart, `recover()` sweeps non-terminal states and retries or
fails them — QUEUED → DISPATCHED → EXECUTING → SUCCESS (or FAILED / TIMEOUT).
**`TelemetryCollector`**: stateless proxy. Routes `telemetry.pull` queries to the appropriate Worker and passes the response
back to the caller. Workers own all aggregation and storage — Kernel is a thin router.
**`Scheduler`**: system metronome. Runs non-overlapping interval jobs for telemetry pulls, health pings, and state pulls on
configurable cadences. Jobs are idempotent — safe to restart with no state loss.
**`HealthMonitor`**: ping-based liveness checker. Sends `health.ping` to every registered Worker on a configurable cadence
and updates the registry — UNKNOWN → HEALTHY → SICK → DEAD (with reconnect path back to HEALTHY).
## The pull-only model
Kernel never receives unsolicited data from Workers. It always initiates — pulling telemetry, pinging health, and pulling state on
cadences set in `opts.cadences`. Workers become reachable and wait; Kernel reaches out on its own schedule.
This is what prevents the kernel from being overwhelmed by upstream pressure and is why Workers are described as passive.
Callers — typically the [Gateway](/concepts/stack/gateway#kernel-connection) — do send command requests to Kernel (Kernel is the receiver for those),
but Kernel then dispatches each command to the owning Worker via its own initiated call.
## Transport
Kernel is the **passive listener** — the [caller always initiates the connection](/concepts/stack/gateway#kernel-connection), over
[Hyperswarm RPC (HRPC)](/reference/glossary#hyperswarm-rpc) — an encrypted peer-to-peer transport addressed by Kernel's public key.
The [deployment topologies connection model](/concepts/deployment-topologies#connection-model) details the active/passive components.
- **Same host (zero-config default)**: Kernel publishes its HRPC public key as hex to a well-known key file
(`/mdk/.kernel-key`) on start; the caller/Gateway reads it from there automatically. The file is not deleted on
shutdown — the key is stable across restarts because the HRPC seed persists in the kernel store
- **Remote or multi-host**: the operator shares the key (`kernel.getPublicKey()`) with the caller/Gateway out-of-band. Kernel can
maintain an allowlist — when configured, the caller/Gateway's DHT public key must be added to `opts.auth.whitelist` before the
connection is accepted
The [Kernel transport reference](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#transports) covers the allowlist key exchange and configuration options.
## What Kernel does not own
Kernel deliberately excludes these concerns and delegates them to other layers:
- **User authentication**: Kernel trusts all messages from any established [`@tetherto/mdk-client`](https://github.com/tetherto/mdk/blob/main/backend/core/client/README.md) connection without
inspecting user identity. Establishing that identity belongs to the tier above, but the Gateway supplies no mechanism for it either:
the checks live in the plugin controllers you write. Reaching Kernel through the Gateway is therefore no more access-controlled than
using `@tetherto/mdk-client` directly, unless your controllers make it so. Kernel's one exception is the write-action path, where it
requires the device-family permission (`miner:w`, `container:w`) in the `authPerms` array the caller sends
- **Business logic and aggregation**: cross-Worker queries, fleet statistics, and site-level aggregation belong in Gateway
controllers, not in the kernel
- **UI and consumer interfaces**: Kernel has no HTTP surface. Consumers connect through the Gateway's REST or MCP
endpoints
## Next steps
- Understand the [Gateway's role as Kernel's consumer](/concepts/stack/gateway)
- Understand [Workers as Kernel's downstream](/concepts/stack/workers)
- Choose a [deployment shape](/concepts/deployment-topologies)
- Start Kernel via the [`@tetherto/mdk` bootstrap API](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md)
- Configure Kernel directly using the [`createKernel()` option surface](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md)
# Workers (/concepts/stack/workers)
## Overview
This page introduces the [Worker](https://github.com/tetherto/mdk/blob/main/backend/workers/README.md) as a development component. It covers what a Worker owns, how Kernel discovers it,
what the capability contract is, and how a Worker Plugin can be spun up to support new hardware.
Read this before integrating new hardware, configuring discovery, or building on top of the Worker protocol.
## What a Worker owns
A Worker wraps a device library and exposes it to Kernel via the MDK Protocol. Workers are the integration handlers between physical
hardware and `@tetherto/mdk-kernel`, and the unyielding source of truth for that hardware. `@tetherto/mdk-kernel` operates purely as
a synchronized state machine over Worker-reported state — it never reads hardware directly.
Workers are **passive**: they become a reachable endpoint and wait. The Kernel initiates every call; Workers only ever respond.
[Deployment topologies connection model](/concepts/deployment-topologies#connection-model) details how this directionality shapes transport choices.
For [approval-gated writes](/concepts/control-plane#approval-gated-writes), Workers answer `write.calls.request` while the Kernel resolves candidate writes, then execute the
approved write as a normal `command.request`.
## Discovery model
Each Worker package supplies its own boot function that constructs its runtime internally (for example `startWhatsminerWorker`,
or `startVendorWorker` if you're [building your own](/guides/workers/build-a-worker) on v1 — see [Add hardware](#add-hardware) for what a v2
Worker Plugin does instead, which has no boot function at all) — there is no single generic `startWorker(WorkerClass, opts)`
entry point. The code samples below use `startYourWorker` as a stand-in for whichever boot function (or `WorkerRuntimeV2` call)
your Worker uses.
How Kernel finds a Worker depends on the [deployment topology](/concepts/deployment-topologies) you're running — that page has the
diagrams and the trade-offs for choosing between single-process, local, and microservices. This section carries only the
Worker-side code for each.
In all cases, the post-discovery sequence is identical — [Kernel requests identity, registers the Worker, then queries its
capabilities](/concepts/stack/kernel#what-kernel-owns). Once connected, all three shapes use the same HyperswarmRPC transport and the same MDK
Protocol envelope (`command.request`, `telemetry.pull`, and so on); only how Kernel first obtains the Worker's RPC public key
differs. After a Worker reaches `READY`, the [Kernel Scheduler](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#scheduler) initiates telemetry pulls and health checks
over HRPC; the Worker remains passive throughout.
### Single-process mode
Skips all network discovery. Register the runtime's public key directly with the live Kernel instance in the same process — no
topic, no directory, no network lookup:
```js
const kernel = await getKernel(opts)
const worker = await startYourWorker(opts)
await kernel.registerWorker(worker.runtime.getPublicKey())
```
Two behaviors differ from the other two modes:
- **Registration**: the host module calls `kernel.registerWorker()` directly with the runtime's public key. The Worker reaches
`READY` synchronously — no `waitForDiscovery()` required
- **Lifecycle**: registration alone does not couple the Worker's shutdown to Kernel's — the host process that constructed the
runtime owns its lifecycle in every mode. Push the Worker's `stop()` onto Kernel's `_cleanup` queue yourself if Kernel shutdown
should cascade to it (see [`bootWorker`](https://github.com/tetherto/mdk/blob/main/examples/full-site/README.md#how-out-of-process-workers-find-the-kernel) for the pattern), or manage it directly in your own shutdown
handler
Use this mode for the [run a mining site tutorial](/tutorials/run-a-site) and [single-process deployments](/guides/deployment/run-single-process-site).
### Local mode
In local mode, Kernel and Workers coordinate through a shared directory on the same machine (default `/.worker-keys/`).
No Hyperswarm topic is joined and no outbound internet connection is required.
**Worker side**: after `runtime.start()`, publish the runtime's RPC key to the shared directory with
`publishWorkerKey` from `@tetherto/mdk`'s local-discovery helpers. The entry is stable across
restarts (the key is seed-derived), so restarting a Worker is a no-op from Kernel's perspective.
```js
const { keysDir, publishWorkerKey } = require('@tetherto/mdk/backend/core/mdk/lib/local-discovery')
const worker = await startYourWorker(opts)
publishWorkerKey(keysDir(root), workerId, worker.runtime.getPublicKey().toString('hex'))
```
**Kernel side**: `getKernel` watches the directory with `fs.watch` and runs a full scan every four seconds. Each entry found triggers
the normal discovery listener (Identity → Capability → Ready), the same sequence used in DHT mode.
```js
const kernel = await getKernel({ discovery: { mode: 'local' } })
```
A custom directory can be passed when the default path is not suitable:
```js
const kernel = await getKernel({ discovery: { mode: 'local', dir: '/shared/mdk-keys' } })
publishWorkerKey('/shared/mdk-keys', workerId, worker.runtime.getPublicKey().toString('hex'))
```
Keys persist across restarts and the directory is read again each time Kernel starts, so Workers and Kernel can start in any order
without coordination.
All processes must share the same filesystem path. Local mode requires every component to run on the same machine — use DHT
mode for Workers on separate hosts.
The Starter site example demonstrates local mode as its default multi-process setup — its
`config/site.deploy.json`'s `discovery` field defaults to `"local"`, and switches to `"dht"` without any other code change.
### Microservices mode
Also called DHT mode: instead of a shared directory or same-process registration, Kernel and the Worker join the same Hyperswarm
topic — the mechanism [production microservices](/guides/deployment/run-all-workers-site) and Workers on separate hosts or networks depend on. Generate a
random 32-byte hex topic in whichever process starts first, persist it somewhere the other process can read it, and pass the
same value to both sides:
```js
const kernel = await getKernel({ topic: '<32-byte-hex>' })
const worker = await startYourWorker({ kernelTopic: '<32-byte-hex>', ...opts })
```
The Worker must join the topic before Kernel starts listening. Start the Worker process first, then start Kernel.
`waitForDiscovery()` polls the registry until discovered Workers reach `READY` state.
The DHT pattern is demonstrated end-to-end by the [full-site example](https://github.com/tetherto/mdk/blob/main/examples/full-site/README.md#how-out-of-process-workers-find-the-kernel)'s `up --discovery dht`.
## Capability contract
[`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/whatsminer/plugin/mdk-contract.json) is the canonical source of truth for a Worker's programmatic capabilities **and** its AI context. MDK
deliberately merges formal validation and semantic guidance into a single JSON contract:
- `description` does double duty as the human UI label and AI edge-case rule (for example, *"Outlet temperature > 85C requires intervention"*)
- `constraints` governs orchestration limits
- `troubleshooting` provides if/then recovery behaviors alongside the payload it evaluates
The exhaustive JSON Schema is `mdk-contract.schema.json`, with a [reference instance at `mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/whatsminer/plugin/mdk-contract.json).
## Add hardware
External integrators add new hardware by building a Worker Plugin that conforms to the strict Device-Lib Contract:
1. Reference [`mdk-contract.schema.json`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/mdk-contract.schema.json) to author the [`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/whatsminer/plugin/mdk-contract.json), validating strict data schemas while injecting
explanations, constraints, and troubleshooting directly into the relevant nodes.
2. Build a Worker Plugin. [The full build walkthrough](/guides/workers/build-a-worker) is the source of truth for the current model: a package
directory ([`mdk-contract.json`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/whatsminer/plugin/mdk-contract.json) + handler files, no `connect`/`disconnect`) hosted by pointing
[`WorkerRuntimeV2`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime-v2.js) at it.
3. Boot the Worker instance and register with `@tetherto/mdk-kernel` using the appropriate [discovery mode](#discovery-model).
`@tetherto/mdk-kernel` detects the peer and pulls its identity and capabilities.
`WorkerRuntime` remains exported and supported: existing Workers built against the older `{ contract, dir, connect,
disconnect? }` object passed to `new WorkerRuntime(plugin, opts)` — for example
[`whatsminer/plugin/index.js`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/whatsminer/plugin/index.js) — still work unchanged; `WorkerRuntimeV2` extends it and
is the model for new hardware.
[`createModuleContext`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/module-context.js) is the private-module-registry primitive that gives each plugin
instance its own `require` cache, so module-level state (a client constructed at load time, say) belongs to that
one instance alone. `WorkerRuntimeV2`, the Gateway, and the MCP server each build one per plugin; `WorkerRuntime`
v1 has no notion of per-device isolation and does not use it.
### v1 Worker Plugins
Legacy, but still what every Worker package under `backend/workers/` ships today — Whatsminer, Antminer, Avalon, and the
rest — not v2 above: a plugin object `{ contract, dir, connect, disconnect? }` passed to `new WorkerRuntime(plugin, opts)` from
[`@tetherto/mdk-worker`](https://github.com/tetherto/mdk/blob/main/backend/core/mdk-worker/lib/worker-runtime.js), with handlers invoked as `(ctx, params)` instead of v2's ambient `(params)`.
`connect`/`disconnect` translate `command.request` and `telemetry.pull` calls into real device I/O — the one thing v1 does that
v2 has no equivalent for, since v2 assumes reaching the device is the handler's own problem, resolved once at load time.
`WorkerRuntime` generalizes the former `MDKWorkerAdapter` (persistent seeds, single HRPC respond loop, DHT topic announce carried
over) and replaces `ThingManager` delegation with per-device handler dispatch; see [Worker Runtime legacy services](https://github.com/tetherto/mdk/blob/main/docs/reference/maintainers/worker-runtime-legacy-services.md)
for the full migration history and the optional built-in services surface. [`whatsminer/plugin/index.js`](https://github.com/tetherto/mdk/blob/main/backend/workers/miners/whatsminer/plugin/index.js)
is a reference v1 plugin implementing `connect`/`disconnect` against a real device.
Build new Worker Plugins on v2 above unless you specifically need v1's `connect`/`disconnect` device-transport model.
## Next steps
- [Configure how often Kernel polls discovered Workers](https://github.com/tetherto/mdk/blob/main/backend/core/kernel/README.md#api)
- [Diagnose startup hangs when outbound network is restricted](/guides/miners/troubleshooting)
- Read the [full build walkthrough](/guides/workers/build-a-worker) for a step-by-step guide to building a new Worker Plugin
# 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](/concepts/stack/agent)
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](/concepts/stack/agent)
- [Understand the Gateway as a development surface](/concepts/stack/gateway)
# 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](/concepts/stack/agent)
# 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](/concepts/architecture#ai-agents-and-the-mcp-server),
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
# 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](/concepts/deployment-topologies)
- 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](/concepts/deployment-topologies) 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](/concepts/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](/concepts/deployment-topologies)
- Browse the [functions](https://github.com/tetherto/mdk/blob/main/backend/core/mdk/README.md) that wire together the [Kernel](/concepts/stack/kernel), [device Workers](/concepts/stack/workers), and the [Gateway](/concepts/stack/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](/concepts/deployment-topologies) 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](/concepts/deployment-topologies) 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](/concepts/deployment-topologies)
- 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](/concepts/stack/kernel), [device Workers](/concepts/stack/workers), and the [Gateway](/concepts/stack/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](/concepts/deployment-topologies) 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](/concepts/deployment-topologies)
- 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](/concepts/stack/gateway).
## 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](/concepts/stack/gateway)
- Read the [Gateway API reference](https://github.com/tetherto/mdk/blob/main/backend/core/gateway/README.md)
- Choose a [deployment shape](/concepts/deployment-topologies)
- [Give an operator a chat interface to the fleet](/guides/agent) by deploying the operator agent behind the Gateway
# Enable the operator agent (/guides/gateway/agent)
## 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](/concepts/architecture#ai-agents-and-the-mcp-server),
over the standalone MCP server; this plugin only gives a human operator a chat surface to talk to it through.
## 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
# 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](/concepts/stack/gateway#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](/concepts/stack/gateway).
## 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](/concepts/stack/gateway) 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](/concepts/stack/gateway)
- Choose a [deployment shape](/concepts/deployment-topologies)
# 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](/concepts/stack/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](/concepts/stack/workers#single-process-mode) 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](/concepts/control-plane#approval-gated-writes) — 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](/concepts/deployment-topologies)
- 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](/concepts/deployment-topologies)
- 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](/concepts/deployment-topologies)
- 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 `baseUrl`, or let the factory resolve one from environment variables:
```ts
const queryClient = createMdkQueryClient({
baseUrl: 'https://app-node.example.com',
})
```
Without an explicit `baseUrl`, the factory checks `VITE_MDK_API_URL` then `MDK_API_URL` before falling back to `http://localhost:3000`.
## 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({
baseUrl: 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.
- [MDK App Toolkit](/concepts/stack/app-toolkit): where UI Foundation fits in the frontend stack.
- [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](/concepts/stack/workers)
### 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](/concepts/security-boundaries)
- See the end-user experience of controlling and monitoring your device via the Worker in [Test a Worker's next steps](/guides/workers/test-a-worker)
# Test a new Worker 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](/concepts/stack/workers) 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](/concepts/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). For a multi-Worker view, parallel Workers, and
multi-site deployments, see [`architecture.md#scaling`](/concepts/architecture#scaling).
## 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](/concepts/stack/workers)
- 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 these modules fit together, see
[Kernel](/concepts/stack/kernel).
## 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](/concepts/stack/kernel): the architectural narrative behind this module split
- Understand [approval-gated writes](/concepts/control-plane): 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#the-mdk-protocol).
## 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](/concepts/control-plane) 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](/concepts/deployment-topologies)
- 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 `