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 checkThis mirrors examples/backend/demo-worker-caller/, which is an
example directory containing one host module, not a standalone npm package.
Prerequisites
- Node.js
>=24(all MDK core packages declare thisenginesconstraint) - 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
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):
npm install github:tetherto/mdk#mainThis 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:
(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:
"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); 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 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:
"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({ kernelKey: 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:
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/backend/core/mdk");
const {
keysDir,
publishWorkerKey,
} = require("@tetherto/mdk/backend/core/mdk/lib/local-discovery");
const { startVendorWorker } = require("./index");
const ROOT = path.join(os.tmpdir(), `vendor-worker-local-${process.pid}`);
async function main() {
let worker;
let kernel;
try {
worker = await startVendorWorker({
workerId: "vendor-demo",
seedDevices: [
{ id: "vendor-0", opts: { host: "127.0.0.1", port: 9001 } },
],
});
publishWorkerKey(
keysDir(ROOT),
"vendor-demo",
worker.runtime.getPublicKey().toString("hex"),
);
kernel = await getKernel({ root: ROOT, discovery: { mode: "local" } });
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 (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: publish from the Worker process, then read from the Kernel process. Order does not matter. Kernel rescans the directory every four seconds on top of watching it, so a Worker that publishes before Kernel starts is found on the first scan, and one whose RPC server is not yet listening is retried on a later one.
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 Step 2); 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, start the Worker first with
kernelTopic, then start Kernel with the same topic. 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/backend/core/mdk");
const { startVendorWorker } = require("./index");
const ROOT = path.join(os.tmpdir(), `vendor-worker-dht-${process.pid}`);
async function main() {
const topic = process.env.MDK_TOPIC || crypto.randomBytes(32).toString("hex");
let worker;
let kernel;
try {
worker = await startVendorWorker({
workerId: "vendor-demo",
kernelTopic: topic,
seedDevices: [
{ id: "vendor-0", opts: { host: "10.0.0.20", port: 9001 } },
],
});
kernel = await getKernel({ root: ROOT, topic });
const workers = await waitForDiscovery(kernel, {
minWorkers: 1,
timeoutMs: 45000,
});
const ready = workers.find(
(w) => w.workerId === "vendor-demo" && w.state === "READY",
);
if (!ready) throw new Error("ERR_WORKER_NOT_READY");
console.log(`READY ${ready.workerId}: ${ready.deviceIds.join(", ")}`);
} finally {
if (kernel) await shutdown(kernel);
if (worker) await worker.stop();
}
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});For separate production processes, each process must install signal handlers and close every handle it owns. DHT topics enable rendezvous; they are not authentication secrets or command-authorization tokens. See the discovery model for 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_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 legacy 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 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 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