Build a third-party Worker
Integrate your own hardware, firmware, or data feed with MDK by shipping a Worker package from your own repo
Build a third-party Worker
This guide is for partners who want to integrate their own hardware, firmware, or data feed with MDK by shipping a
Worker plugin package from their own public or private repository — no fork of this monorepo and no PR into
tetherto/mdk required.
It walks through building a Worker from scratch, end to end: the device client, the mdk-contract.json, the handlers,
the mock, the tests, and finally how to depend on MDK's runtime from your own repo and register your Worker with a live
Kernel.
Everything below is a generalization of one real, runnable reference implementation already in this repo:
backend/workers/samples/demo-worker/. It proves this pattern works with zero
dependency on this monorepo's optional worker-infra services (provisioning stores, alert templates, stats
aggregation) — just WorkerRuntime and the Worker Plugin contract shape. Keep it open as the minimal reference while
you follow along. This guide links its corresponding files and 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 WorkerRuntime, and device ID for a
runtime device identity. Worker is capitalized when it means the MDK component. Older APIs may still use
thing for a device. For the broader architecture, read How MDK works.
Also read the Worker install pattern and
Worker discovery model.
What you get
your-worker-repo/
package.json
index.js # exports { plugin } — the Worker Plugin, nothing more
lib/
device-client.js # plain I/O against your vendor's native API — no MDK concepts
plugin/
index.js # the Worker Plugin: { contract, dir, connect, disconnect? }
mdk-contract.json # the engineering + AI-context contract
src/
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/plugin.test.js # drives plugin.connect() + handlers directly against the mock
# — no WorkerRuntime involvedThis is exactly the shape of demo-worker with the vendor name genericized: a package
that can be fully built and tested with zero dependency on WorkerRuntime. Nothing about this layout is enforced
by the framework — WorkerRuntime only cares about the plugin object it receives
({ contract, dir, connect, disconnect? }) — but matching it keeps your package legible to anyone who has read
another MDK Worker.
Prerequisites
- Node.js
>=24(all MDK core packages declare thisenginesconstraint) - 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
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 a different domain to @tetherto):
{
"name": "@your-org/mdk-worker-vendor",
"version": "0.1.0",
"description": "MDK Worker 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"
}
}The examples and current plugin loader use CommonJS: plugin and handler files are loaded with require(). Set
"type": "commonjs" or use .cjs files. An ESM-only package ("type": "module" with .js handlers) is not a
supported plugin-loader path today. WorkerRuntime brings its own transport dependencies (@hyperswarm/rpc,
hyperswarm, hyperdht) — you don't redeclare them. brittle and standard are the repository's test and lint
tools; substitute your own tooling if you prefer.
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 — just a function that returns an object with methods your handlers will call.
lib/device-client.js, modeled on
demo-worker/plugin/lib/device-client.js:
"use strict";
function createClient({ host, port, timeoutMs = 5000 }) {
const base = `http://${host || "127.0.0.1"}:${port}`;
const call = async (path, opts = {}) => {
try {
const res = await fetch(base + path, {
...opts,
signal: opts.signal || AbortSignal.timeout(timeoutMs),
});
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;
}
};
return {
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 }),
}),
};
}
module.exports = { createClient };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.
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 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.
plugin/mdk-contract.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:
descriptionis 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, andconstraintsare published metadata;WorkerRuntimenormalizes positional parameters but does not validate or enforce them. The command handler must reject missing, wrong-type, out-of-range, or disallowed values with stableERR_*failures and enforce every declared cooldown.errorsmaps your device's error codes to human-readable text; throwErrormessages that contain these codes so operators and agents can look them up.health.alertsis optional because a plugin without an alerting layer must not invent alerts.metadata,capabilities.telemetry,capabilities.commands,capabilities.health.supportedStates, andcapabilities.errorsare publication/catalogue requirements. At runtime, the current loader's minimum is looser: it requiresmetadataandcapabilitiesobjects 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 the plugin's directory) to a function with a fixed
signature. The plugin loader require()s every declared handler eagerly at construction time — a missing file, a
non-function export, or a duplicate name throws immediately, before your Worker ever starts
(ERR_PLUGIN_HANDLER_NOT_FOUND). 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 as soon as the host process constructs WorkerRuntime.
Telemetry handler — async (ctx, params) => value. ctx is { deviceId, device, config, services }. The context
object is shallow-frozen: handlers cannot replace its four top-level properties, but nested objects are not made
immutable. The host owns services, the plugin's connect() result owns device, and the host owns config;
handlers should treat config as read-only and mutate device state only through the device client's explicit
methods. One file per telemetry field from Step 3:
plugin/src/telemetry/hashrate-rt.js:
"use strict";
module.exports = async (ctx) => (await ctx.device.getSummary()).hashrate_ths;plugin/src/telemetry/power.js:
"use strict";
module.exports = async (ctx) => (await ctx.device.getSummary()).power_w;plugin/src/telemetry/temperature.js:
"use strict";
module.exports = async (ctx) => (await ctx.device.getSummary()).board_temp_c;Command handler — async (ctx, params) => result. Return value becomes payload.result; a thrown Error becomes
{ status: 'FAILED', error: err.message } in the response — this is how your errors map in the contract actually
reaches the requesting client. One file per command from Step 3:
plugin/src/commands/reboot.js:
"use strict";
const COOLDOWN_MS = 5 * 60 * 1000;
const policyByDevice = new Map();
function audit(ctx, outcome, errorCode) {
console.info(
JSON.stringify({
event: "physical_command",
command: "reboot",
deviceId: ctx.deviceId,
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 (ctx) => {
const now = Date.now();
const policy = policyByDevice.get(ctx.deviceId) || {
lastAttemptAt: 0,
running: false,
};
if (policy.running) {
audit(ctx, "rejected", "ERR_COMMAND_IN_PROGRESS");
throw new Error("ERR_COMMAND_IN_PROGRESS: reboot");
}
const remaining = COOLDOWN_MS - (now - policy.lastAttemptAt);
if (remaining > 0) {
audit(ctx, "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.
policy.lastAttemptAt = now;
policy.running = true;
policyByDevice.set(ctx.deviceId, policy);
audit(ctx, "started");
try {
const result = await ctx.device.reboot();
audit(ctx, "succeeded");
return result;
} catch (err) {
audit(ctx, "failed", stableErrorCode(err));
throw err;
} finally {
policy.running = false;
}
};plugin/src/commands/set-power-mode.js:
"use strict";
const ALLOWED_MODES = new Set(["eco", "normal", "high"]);
function audit(ctx, outcome, errorCode) {
console.info(
JSON.stringify({
event: "physical_command",
command: "setPowerMode",
deviceId: ctx.deviceId,
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(ctx, code) {
audit(ctx, "rejected", code);
throw new Error(code);
}
module.exports = async (ctx, params) => {
if (!params || params.mode === undefined) reject(ctx, "ERR_MODE_REQUIRED");
if (typeof params.mode !== "string") reject(ctx, "ERR_MODE_TYPE");
if (!ALLOWED_MODES.has(params.mode)) reject(ctx, "ERR_BAD_POWER_MODE");
audit(ctx, "started");
try {
const result = await ctx.device.setPowerMode(params.mode);
audit(ctx, "succeeded");
return result;
} catch (err) {
audit(ctx, "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:
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");The maps above are deliberately process-local teaching state. If a physical cooldown must survive restarts or multiple
Worker hosts, store lastAttemptAt in process-owned persistent storage and update it atomically before device I/O.
The JSON audit lines demonstrate the minimum event shape, including rejected and failed outcomes; production hosts
must send these events to a durable audit sink. Actor identity and request correlation are owned by the authenticated
Gateway/control plane because they are not currently present in the handler context. 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.
Assemble the Worker plugin
The plugin is the object WorkerRuntime is constructed with: the contract, the plugin's own directory (so handler
paths resolve), and a connect function that turns one device's config into the device object every handler sees.
An optional disconnect runs on stop().
plugin/index.js, modeled on demo-worker/plugin/index.js:
"use strict";
const { createClient } = require("../lib/device-client");
module.exports = {
contract: require("./mdk-contract.json"),
dir: __dirname,
connect: async (config, { deviceId }) => {
const device = createClient(config);
// Probe once so an unreachable device is held offline from boot rather
// than surfacing a connection error on every telemetry pull.
await device.getSummary();
return device;
},
// disconnect: async (device, { deviceId }) => { /* optional cleanup */ }
// If you uncomment disconnect, put a comma after the connect function above.
};A device whose connect() throws is held offline — requests to it return ERR_DEVICE_UNAVAILABLE — without taking
down the runtime or its sibling devices. The current runtime has no reconnect loop: it calls connect() during
runtime.start() only. The host process owns backoff, logging, and recovery policy; today, bringing an initially
offline device online requires stopping and restarting that Worker host after restoring connectivity.
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:
"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 createClient already defines that method).
Test the plugin against the mock
Drive plugin.connect() and the handler modules directly against the mock — this exercises your whole plugin
(connection probing, telemetry translation, command dispatch, error mapping) with no WorkerRuntime in the loop,
so it needs nothing beyond what you've already written in Steps 1–7. demo-worker's own
tests/unit/plugin.test.js is the complete worked example of
this style.
"use strict";
const test = require("brittle");
const plugin = require("../../plugin");
const hashrateRt = require("../../plugin/src/telemetry/hashrate-rt");
const reboot = require("../../plugin/src/commands/reboot");
const setPowerMode = require("../../plugin/src/commands/set-power-mode");
const vendorMock = require("../../mock/server");
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 device = await plugin.connect(
{ host: "127.0.0.1", port: 9001 },
{ deviceId: "vendor-0" },
);
const ctx = Object.freeze({
deviceId: "vendor-0",
device,
config: {},
services: null,
});
t.is(await hashrateRt(ctx), 200, "hashrate_rt reads the mock");
const result = await setPowerMode(ctx, { mode: "eco" });
t.is(result.power_mode, "eco", "command reaches the mock");
await t.exception(() => setPowerMode(ctx, {}), /ERR_MODE_REQUIRED/);
await t.exception(() => setPowerMode(ctx, { mode: 1 }), /ERR_MODE_TYPE/);
await t.exception(
() => setPowerMode(ctx, { mode: "turbo" }),
/ERR_BAD_POWER_MODE/,
);
const failingCtx = Object.freeze({
...ctx,
deviceId: "vendor-command-failure",
device: {
setPowerMode: async () => {
throw new Error("ERR_DEVICE_CALL_FAILED");
},
},
});
await t.exception(
() => setPowerMode(failingCtx, { mode: "eco" }),
/ERR_DEVICE_CALL_FAILED/,
);
t.ok(
auditEvents.some(
(e) => e.command === "setPowerMode" && e.outcome === "rejected",
),
);
t.ok(
auditEvents.some(
(e) => e.command === "setPowerMode" && e.outcome === "failed",
),
);
});
test("reboot enforces concurrency and cooldown after every attempt", async (t) => {
let release;
const pending = new Promise((resolve) => {
release = resolve;
});
const concurrentCtx = Object.freeze({
deviceId: "vendor-concurrent",
device: { reboot: () => pending },
config: {},
services: null,
});
const first = reboot(concurrentCtx);
await t.exception(() => reboot(concurrentCtx), /ERR_COMMAND_IN_PROGRESS/);
release({ ok: true });
await first;
await t.exception(() => reboot(concurrentCtx), /ERR_COMMAND_COOLDOWN/);
const failingCtx = Object.freeze({
deviceId: "vendor-failing",
device: {
reboot: async () => {
throw new Error("ERR_DEVICE_CALL_FAILED");
},
},
config: {},
services: null,
});
await t.exception(() => reboot(failingCtx), /ERR_DEVICE_CALL_FAILED/);
await t.exception(() => reboot(failingCtx), /ERR_COMMAND_COOLDOWN/);
});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, concurrent-command rejection, cooldown after successful and failed
attempts, connect() throwing when the mock is unreachable, a firmware-side error surfacing with your contract's
error code, 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.
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.jsonvalidates againstmdk-contract.schema.json; every telemetry/command entry has a unique name and a CommonJS handler path that resolves to a function - Every
descriptionstates 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
- Offline-at-start behavior and the host's restart/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 asserts the Worker reaches
READY, exposes its device IDs, and serves telemetry throughcreateMdkClient -
npm run lintand your test suite are wired into your own CI
Troubleshooting
Plugin loading may fail synchronously during new WorkerRuntime(plugin, opts):
| Error | Diagnostic and remediation |
|---|---|
ERR_PLUGIN_REQUIRED | The first argument is absent or not an object; pass the exported Worker Plugin |
ERR_PLUGIN_CONNECT_NOT_FUNCTION | Export connect(config, { deviceId }) as a function |
ERR_PLUGIN_DISCONNECT_NOT_FUNCTION | Remove disconnect or export it as a function |
ERR_PLUGIN_DIR_MISSING | Set plugin.dir to a non-empty absolute directory such as __dirname |
ERR_PLUGIN_CONTRACT_MISSING | Export the parsed mdk-contract.json as plugin.contract |
ERR_PLUGIN_CONTRACT_METADATA_MISSING | contract.metadata is missing or not an object |
ERR_PLUGIN_CONTRACT_CAPABILITIES_MISSING | contract.capabilities is missing or not an object |
ERR_PLUGIN_SECTION_NOT_ARRAY: <section> | capabilities.telemetry or capabilities.commands must be an array |
ERR_PLUGIN_ENTRY_NAME_MISSING: <section> | Give every telemetry/command entry a non-empty string name |
ERR_PLUGIN_HANDLER_MISSING: <section>.<name> | Add that entry's relative handler path |
ERR_PLUGIN_HANDLER_NOT_FOUND: <section>.<name>: <resolved path>: <nested error> | Verify the displayed resolved path is relative to plugin.dir. Preserve and inspect the nested module error: the file may exist while one of its own imports is missing or incompatible |
ERR_PLUGIN_HANDLER_NOT_FUNCTION: <section>.<name>: <resolved path> | The CommonJS module must assign a function to module.exports |
ERR_PLUGIN_DUPLICATE_NAME: <section>.<name> | Rename or remove the duplicate entry in that section |
These fire only once a host process constructs WorkerRuntime with your plugin — for errors from that point on
(runtime construction, Kernel registration, live requests), see Troubleshooting in Test a Worker with MDK.
Next steps
- Test your new Worker's integration with MDK
- Understand the security boundaries
Understand the end-user experience of controlling and monitoring your device via the Worker:
- Build a minimal dashboard around one Worker
- Run the full-site example with the supported Worker fleet
- Connect an AI agent via the MCP server to query and command your Workers