Test a new Worker
Test a third party Worker package
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.
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). 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:
your-worker-host/
index.js # host module: WorkerRuntimeV2, devices, lifecycle
run-live.js # live Kernel registration and compatibility checkyour-worker-host/ is a directory holding one host module, not a standalone npm package.
Prerequisites
- Node.js
>=24(all MDK core packages declare thisenginesconstraint) - npm 11 (< 12)
- A completed Worker plugin package, including its bundled mock device
- Comfort with plain async JS — no additional MDK framework knowledge is required beyond what building the package already covered
Check the package
Run the mdk CLI checks against the package directory before you host it. Both exit 1 on any
problem, so the same commands work as a CI gate:
mdk validate worker --path <worker-package-dir>
mdk validate worker --path <worker-package-dir> --smokeThe first command reads package.json and mdk-contract.json without running anything. It checks the contract
against its schema, that names are unique, that number params have valid min/max bounds, and that every handler
file exists and compiles.
--smoke also calls every handler against the package's bundled mock. setup() in smoke.config.js at the package
root starts the mock and returns these fields:
| Field | Purpose |
|---|---|
config | Device opts the handlers see, usually the mock's address and port |
env | Device env the handlers see |
pluginConfig | Plugin config the handlers see |
commands | Sample params per command name, each run against the mock. Leave out commands that reboot or disconnect a device. |
teardown | Stops the mock. Without it, --smoke skips the device-stopped checks and prints a warning. |
offlineTelemetry | Telemetry channels served from the Worker's own store, such as a history log, that keep answering once the device stops |
While the mock runs, every telemetry channel must return its declared type, and every sample command must succeed. The
param gate must reject each bounded number param just outside its range, and each command handler must itself reject
a value below its first bounded param's min, since a Worker is reachable without the Kernel's gate in front of it.
After teardown(), every telemetry channel and sample command must fail with an ERR_* error code before the deadline
(--timeout, 10000 ms by default), so a stopped device never reports a stale reading. Two kinds of channel are
exempt:
- A
healthchannel may report a status other thanOKinstead of failing - A channel listed in
offlineTelemetrymust still return its declared type
Install MDK
Install the packages this guide hosts and calls:
npm install @tetherto/mdk-worker @tetherto/mdk-core @tetherto/mdk-client@tetherto/mdk-worker ships WorkerRuntimeV2. @tetherto/mdk-core ships getKernel, startGateway and
waitForDiscovery, used to register with a live Kernel, and exposes the local
discovery helpers at @tetherto/mdk-core/lib/local-discovery. @tetherto/mdk-client ships createMdkClient for the
compatibility check.
Check node --version first. On Node.js older than 24, these installs still exit 0 but resolve placeholder releases
that ship no code, so the first require() fails with MODULE_NOT_FOUND. npm reports the mismatch only as an
EBADENGINE warning. Upgrade to Node.js 24 and reinstall.
Write the host module
host/index.js:
"use strict";
const path = require("path");
const { WorkerRuntimeV2 } = require("@tetherto/mdk-worker");
const WORKER_DIR = path.resolve(__dirname, "../your-worker-repo");
async function startVendorWorker({ workerId, kernelTopic, allowedKeys, seedDevices }) {
const runtime = new WorkerRuntimeV2(WORKER_DIR, {
workerId,
kernelTopic: kernelTopic || null,
allowedKeys,
devices: (seedDevices || []).map((d) => ({
deviceId: d.id,
config: d.opts,
})),
});
await runtime.start();
return {
runtime,
stop: () => runtime.stop(),
};
}
module.exports = { startVendorWorker };WorkerRuntimeV2's first argument is the Worker plugin package's own directory, the same one that holds its
mdk-contract.json (see Build a third-party Worker); there is no plugin module to require().
Required options are workerId, a non-empty devices array and allowedKeys. Each device's config object here becomes the
ambient opts its handlers read from @tetherto/mdk-worker/device. kernelTopic is needed only for DHT discovery.
Worker RPC is fail-closed: the runtime answers only the public keys in allowedKeys (or added later with
runtime.allowKey(key)), and runtime.start() throws ERR_WORKER_ALLOWED_KEYS_REQUIRED when there are none. The
key to allow is the Kernel's client key, kernel.getClientPublicKey(), the one it dials Workers with. Each of the
next three steps gets that key a different way; see Trust and admission for the full model.
Without a store, WorkerRuntimeV2 generates a new RPC keypair on restart. Pass a process-owned store if deployment
requires stable identity. The host process also owns persistence, sampling loops, retries, secrets, and shutdown.
WorkerRuntimeV2 also exposes two read accessors for the host process: getPublicKey() returns the runtime's RPC
public key (used to register with Kernel, shown in the next step), and getDeviceContext(deviceId) returns a frozen
{ deviceId, config, services } for a device that is currently online, or null otherwise. There is no
device key: a directory-loaded plugin has no per-device client object for the host to reach into, since handler
modules bind to their device privately through the ambient context (see Step 4 of Build a third-party Worker). A host
process that needs to act on a live device drives it the same way a Gateway request would, through
runtime.handleRequest(...), rather than through getDeviceContext(...).device.
Register directly with a live Kernel
When Kernel and the Worker host share a process, register the runtime's public key directly. The following
host script (save it next to your worker as e.g. host/run-live.js) proves that Kernel accepted the Worker, that it reached READY, and that telemetry traverses the
real client → Kernel → Worker path:
"use strict";
const os = require("os");
const path = require("path");
const {
getKernel,
waitForDiscovery,
shutdown,
} = require("@tetherto/mdk-core");
const { createMdkClient } = require("@tetherto/mdk-client");
const {
callerKeyPair,
trustKernel,
} = require("@tetherto/mdk-core/lib/local-discovery");
const { startVendorWorker } = require("./index");
const vendorMock = require("../your-worker-repo/mock/server");
const ROOT = path.join(os.tmpdir(), `vendor-worker-${process.pid}`);
function onceListening(mock) {
if (mock.server.listening) return Promise.resolve();
return new Promise((resolve) => mock.server.once("listening", resolve));
}
function withTimeout(promise, timeoutMs, code) {
let timer;
const timeout = new Promise((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(code)), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
async function main() {
let mock;
let worker;
let kernel;
let client;
try {
mock = vendorMock.createServer({
host: "127.0.0.1",
port: 9001,
hashrateThs: 200,
});
await onceListening(mock);
kernel = await getKernel({ root: ROOT });
worker = await startVendorWorker({
workerId: "vendor-demo",
allowedKeys: [kernel.getClientPublicKey()],
seedDevices: [
{ id: "vendor-0", opts: { host: "127.0.0.1", port: 9001 } },
],
});
// Follow every caller the Kernel admits later (a Gateway, for instance).
trustKernel(worker.runtime, kernel);
await kernel.registerWorker(worker.runtime.getPublicKey());
const workers = await waitForDiscovery(kernel, {
minWorkers: 1,
timeoutMs: 30000,
});
const ready = workers.find(
(w) => w.workerId === "vendor-demo" && w.state === "READY",
);
if (!ready || !ready.deviceIds.includes("vendor-0")) {
throw new Error("ERR_WORKER_NOT_READY");
}
// The Kernel is fail-closed too: admit this script's own caller key, read-only.
const caller = callerKeyPair(path.join(ROOT, "caller"));
kernel.allowKey(caller.publicKey, ["read"]);
client = createMdkClient({
kernelKey: kernel.getPublicKey(),
kernelKeyPair: caller,
});
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:
READY vendor-demo: vendor-0
hashrate_rt=200The 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 local-directory discovery on one machine
When the Worker host and Kernel are separate processes on the same machine, neither registerWorker() nor a DHT
topic is needed: the Worker publishes its RPC key to a shared directory and Kernel picks it up from there. Both sides
default that directory to <root>/.worker-keys, so passing the same root is the whole configuration.
"use strict";
const os = require("os");
const path = require("path");
const {
getKernel,
waitForDiscovery,
shutdown,
} = require("@tetherto/mdk-core");
const {
keysDir,
publishWorkerKey,
trustedKeysDir,
trustKeysFromDir,
waitForKernelKey,
} = require("@tetherto/mdk-core/lib/local-discovery");
const { startVendorWorker } = require("./index");
const ROOT = path.join(os.tmpdir(), `vendor-worker-local-${process.pid}`);
async function main() {
let worker;
let trusted;
let kernel;
try {
// Kernel side: publishes its client key to <root>/.trusted-keys.
kernel = await getKernel({ root: ROOT, discovery: { mode: "local" } });
// Worker side: allow the Kernel's published key, then every key published
// there later, and publish the Worker's own RPC key for discovery.
const trustDir = trustedKeysDir(ROOT);
worker = await startVendorWorker({
workerId: "vendor-demo",
allowedKeys: [await waitForKernelKey(trustDir)],
seedDevices: [
{ id: "vendor-0", opts: { host: "127.0.0.1", port: 9001 } },
],
});
trusted = trustKeysFromDir(worker.runtime, trustDir);
publishWorkerKey(
keysDir(ROOT),
"vendor-demo",
worker.runtime.getPublicKey().toString("hex"),
);
const workers = await waitForDiscovery(kernel, {
minWorkers: 1,
timeoutMs: 30000,
});
const ready = workers.find(
(w) => w.workerId === "vendor-demo" && w.state === "READY",
);
if (!ready) throw new Error("ERR_WORKER_NOT_READY");
console.log(`READY ${ready.workerId}: ${ready.deviceIds.join(", ")}`);
} finally {
if (trusted) trusted.stop();
if (kernel) await shutdown(kernel);
if (worker) await worker.stop();
}
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});The two halves are shown in one script here so it runs as a single check, but the mode exists for the split case:
the Kernel process publishes its client key, and the Worker process waits for it (waitForKernelKey gives up after
30 seconds with ERR_KERNEL_CLIENT_KEY_NOT_FOUND) before it starts and publishes its own key. Kernel rescans the
worker-keys directory every four seconds on top of watching it, so a Worker whose RPC server is not yet listening is
retried on a later scan.
Pass a storeDir to WorkerRuntimeV2 if you restart Workers. The published key is only stable across restarts when
the runtime has a store to persist its seeds in (see Write the host module); without one, every restart looks like a new peer to
Kernel and stale .key files accumulate.
Every process must resolve the same filesystem path, so this mode only covers one machine. Workers on separate hosts need DHT discovery, in the next step.
Use DHT discovery across processes or hosts
For DHT discovery, generate and securely distribute one 32-byte hex topic. Admission is fail-closed in both
directions, and nothing is shared automatically across hosts: give the Worker the Kernel's client key
(kernel.getClientPublicKey(), stable across restarts of the same Kernel root) in allowedKeys, and give the
Kernel the Worker's RPC key, in discovery.workerKeys at start or with kernel.allowWorkerKey() later. Kernel ignores
a Worker whose key it was not given. Do not also call registerWorker():
"use strict";
const crypto = require("crypto");
const os = require("os");
const path = require("path");
const {
getKernel,
waitForDiscovery,
shutdown,
} = require("@tetherto/mdk-core");
const { startVendorWorker } = require("./index");
const ROOT = path.join(os.tmpdir(), `vendor-worker-dht-${process.pid}`);
async function main() {
const topic = process.env.MDK_TOPIC || crypto.randomBytes(32).toString("hex");
let worker;
let kernel;
try {
kernel = await getKernel({ root: ROOT, topic });
worker = await startVendorWorker({
workerId: "vendor-demo",
kernelTopic: topic,
// On separate hosts, copy this hex key to the Worker host's config.
allowedKeys: [kernel.getClientPublicKey().toString("hex")],
seedDevices: [
{ id: "vendor-0", opts: { host: "10.0.0.20", port: 9001 } },
],
});
kernel.allowWorkerKey(worker.runtime.getPublicKey().toString("hex"));
const workers = await waitForDiscovery(kernel, {
minWorkers: 1,
timeoutMs: 45000,
});
const ready = workers.find(
(w) => w.workerId === "vendor-demo" && w.state === "READY",
);
if (!ready) throw new Error("ERR_WORKER_NOT_READY");
console.log(`READY ${ready.workerId}: ${ready.deviceIds.join(", ")}`);
} finally {
if (kernel) await shutdown(kernel);
if (worker) await worker.stop();
}
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});For separate production processes, each process must install signal handlers and close every handle it owns. DHT topics enable rendezvous; they are not authentication secrets or command-authorization tokens. See the discovery model for how the three modes compare.
Troubleshooting
Runtime construction
new WorkerRuntimeV2(dir, opts) runs two phases synchronously: it loads and validates the Worker plugin package at
dir first (see Troubleshooting in Build a third-party Worker for
ERR_WORKER_DIR_REQUIRED and the contract/handler errors), then validates opts:
| Error | Diagnostic and remediation |
|---|---|
ERR_WORKER_ID_REQUIRED | Pass a non-empty string workerId |
ERR_WORKER_ALLOWED_KEYS_REQUIRED | Thrown by runtime.start(): pass the Kernel's client key in allowedKeys (Steps 3–5 show where it comes from). allowAll: true admits every peer and is for local experiments only |
ERR_DEVICES_REQUIRED | Pass a non-empty devices array, unless this is an intentional provisioning-first host using allowEmptyDevices |
ERR_DEVICE_ID_MISSING | Every device spec needs a non-empty string deviceId |
ERR_DEVICE_ID_DUPLICATE: <id> | Device IDs must be unique within one runtime |
ERR_DEVICE_CONFIG_INVALID: <id> | config, when supplied, must be a non-null object |
allowEmptyDevices opts a host into a provisioning-first bootstrap: the runtime constructs with zero devices instead
of throwing ERR_DEVICES_REQUIRED, then takes registerThing writes (a built-in command, see
Worker Runtime store services) that persist new device configs to the store. Those writes
only take effect once the host is stopped and restarted with the provisioned set — there is no hot-add. It is off by
default; every shipped miner Worker in this monorepo sets it to true in its boot function.
Startup and discovery
| Symptom | Diagnostic and remediation |
| ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| waitForDiscovery() returns no READY Worker | For direct registration, await runtime.start() and kernel.registerWorker(runtime.getPublicKey()). For DHT, start the Worker first, verify both processes use the same 32-byte hex topic and can reach the DHT network, and that the Worker's RPC key is in Kernel's discovery.workerKeys (or admitted with kernel.allowWorkerKey()) |
| Worker is present but never READY | Inspect identity and capability failures. Confirm at least one device ID is reported and the contract has valid metadata and capabilities |
Every device reports online as soon as runtime.start() returns; a directory-loaded plugin has no boot-time probe,
so an unreachable device is never a startup symptom (see Step 5 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: <id> | The request targeted an ID not seeded in this runtime; compare it with the Kernel registry's deviceIds |
ERR_DEVICE_ID_REQUIRED: <type> | A named telemetry pull omitted its target device ID |
ERR_DEVICE_ID_REQUIRED | A command omitted its target device ID |
ERR_UNKNOWN_QUERY_TYPE: <type> | Use metrics or the exact name of a telemetry entry; the entry's return type is not its channel name |
ERR_UNKNOWN_COMMAND: <name> | Use the exact declared command name and confirm its handler loaded |
ERR_UNKNOWN_ACTION: <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 in Build a
third-party Worker.
Next steps
- Understand the security boundaries
Understand the end-user experience of controlling and monitoring your device via the Worker:
- Build a minimal dashboard around one Worker
- Run the Starter site example with a supervised, multi-Worker fleet
- Connect the operator agent to query and command your Workers over MCP