Workers
Workers as integration components — discovery model, capability contract, and adding hardware
Overview
This page introduces the Worker 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 details how this directionality shapes transport choices.
For 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 on v1 — see 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 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. 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 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:
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 reachesREADYsynchronously — nowaitForDiscovery()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_cleanupqueue yourself if Kernel shutdown should cascade to it (seebootWorkerfor the pattern), or manage it directly in your own shutdown handler
Use this mode for the run a mining site tutorial and single-process deployments.
Local mode
In local mode, Kernel and Workers coordinate through a shared directory on the same machine (default <root>/.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.
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.
const kernel = await getKernel({ discovery: { mode: 'local' } })A custom directory can be passed when the default path is not suitable:
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 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:
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's up --discovery dht.
Capability contract
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:
descriptiondoes double duty as the human UI label and AI edge-case rule (for example, "Outlet temperature > 85C requires intervention")constraintsgoverns orchestration limitstroubleshootingprovides 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.
Add hardware
External integrators add new hardware by building a Worker Plugin that conforms to the strict Device-Lib Contract:
- Reference
mdk-contract.schema.jsonto author themdk-contract.json, validating strict data schemas while injecting explanations, constraints, and troubleshooting directly into the relevant nodes. - Build a Worker Plugin. The full build walkthrough is the source of truth for the current model: a package
directory (
mdk-contract.json+ handler files, noconnect/disconnect) hosted by pointingWorkerRuntimeV2at it. - Boot the Worker instance and register with
@tetherto/mdk-kernelusing the appropriate discovery mode.@tetherto/mdk-kerneldetects 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 — still work unchanged; WorkerRuntimeV2 extends it and
is the model for new hardware.
createModuleContext 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, 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
for the full migration history and the optional built-in services surface. 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
- Diagnose startup hangs when outbound network is restricted
- Read the full build walkthrough for a step-by-step guide to building a new Worker Plugin