Gateway plugins
Use the default Gateway plugins, mount third-party plugins, and build your own using the mdk-plugin.json format
Overview
The Gateway serves HTTP routes from apps. An app is a package with an mdk-app.yaml manifest, a route table
(mdk-plugin.json, in plugin/ or at the package root) and one controller file per route. Each app runs in its own process
under @tetherto/mdk-app-runtime: the Gateway reads only the route table and forwards each request to
that runtime over HRPC, so no app code runs inside the Gateway. A Gateway serves exactly the apps your stack declares in
spec.apps[] and nothing else.
An app builds its own @tetherto/mdk-client to call into the Kernel. No knowledge of the MDK
Protocol envelope or internal message shapes is required.
Prerequisites
- The
mdkCLI is installed, with a projectmdk.yaml(mdk onboardwrites one)
Apps MDK ships
The supported plugins reference is the source of truth for the apps MDK ships and the routes each serves. Declare one by package name:
# mdk.yaml
spec:
gateway:
port: 3847
apps:
- name: demo
package: "@tetherto/mdk-app-demo"A stack serves only the apps it declares. Any other path answers 404. If a React adapter hook reads a
route, declare the app that serves it.
Third-party apps are documented by their own manifests. mdk add app <source> installs one from npm or GitHub, checks it
with the same rules as mdk validate app, and only then adds it under spec.apps.
Create an app
mdk create app fleetThis scaffolds apps/fleet, links it into the project's package.json, and adds it under spec.apps in mdk.yaml
(--no-stack-entry prints the entry instead). The scaffold carries one read-only summary route:
apps/fleet/
├── mdk-app.yaml # app manifest
├── package.json
└── plugin/
├── mdk-plugin.json # route table
├── lib/client.js # the app's MDK client
└── controllers/summary.js # one module per routemdk-app.yaml describes the app to the operator:
| Field | Status | Description |
|---|---|---|
id | Required | Reverse-DNS id, e.g. com.example.fleet |
version | Required | Semver, equal to the package.json version |
displayName | Required | Human name |
description | Optional | One-line summary |
permissions | Optional | Rules { resources, verbs, resourceNames? } the app asks the operator to approve |
config | Optional | Default config handed to the controllers |
The Kernel does not enforce permissions yet. They are declared for operator review only: the app runtime's Kernel key
is admitted with full access, like every key in .mdk/trusted-keys/.
mdk validate app fleet checks the manifest, its version against package.json, the permission rules and the route
table, by the same rules the app runtime and the Gateway apply at boot (the version check is the validator's own).
Write the routes
2.1 Edit the route table
mdk-plugin.json declares the plugin identity (name, version) and a routes array. Each route needs an id, a
handler path, and either an http block with a method and path, or those same method/path fields flattened to the route's top level;
the bundled agent app uses the flat form. Rather than copy a synthetic example, start from a real manifest and trim it:
backend/apps/demo/mdk-plugin.json: two read-onlyGETroutes with query parameters, each annotated withsafetyexamples/mvp-site/backend/gateway-plugins/site/mdk-plugin.json: four routes includingGETs with query parameters, andPOSTs with arequestBodyand path parametersbackend/apps/agent/mdk-plugin.json:auth: trueroutes in the flat form, one of them a stream route
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 explains what each field means and what the loader requires.
2.2 Write a controller
A controller builds its own @tetherto/mdk-client once, from the app's
context config, in a lib/client.js every controller in the app requires.
Every controller exports an async function (req):
// 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 }
}// 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
}
}Declare and mount it
mdk create app already wrote this entry; a hand-written one has the same shape:
spec:
apps:
- name: fleet # ^[a-z][a-z0-9-]*$, unique in the stack
package: fleet # npm name or local path
config: {} # optional: overrides the mdk-app.yaml config defaults
mount: / # optional: serve the routes at the root instead of /apps/fleetThe Gateway serves an app's routes under /apps/<name> unless mount is set, so the scaffold's GET /api/fleet/summary
answers at /apps/fleet/api/fleet/summary, or at /api/fleet/summary with mount: /. Mount an app at the root only when a
client calls its paths there, as the agent app needs for its UI.
Run it
mdk run # Kernel, Workers, one runtime process per app, then the Gateway
mdk run app fleet # or only this app, in the foreground, beside `mdk run kernel` and `mdk run gateway`The runtime loads the route table and every handler before it serves, so a missing file or invalid manifest fails that app
at startup. It accepts calls only from the Gateway's key (.mdk/gateway.key); until it is up, its routes answer 503.
curl http://localhost:3847/apps/fleet/api/fleet/summaryExposing an app's routes to the operator agent turns them into MCP tools with no separate manifest.
Start apps from code
Outside the CLI, pass each app to startGateway() from @tetherto/mdk-core as apps (the complete list, nothing else is
mounted) and run each one with createAppRuntime(), allowing only the Gateway's key:
const path = require('path')
const { startGateway } = require('@tetherto/mdk-core')
const { createAppRuntime } = require('@tetherto/mdk-app-runtime')
const appDir = path.join(__dirname, 'apps/fleet')
const runtime = createAppRuntime({
name: 'fleet',
appDir,
kernelKey: kernel.getPublicKey(),
storeDir: './run/apps/fleet',
keyFile: './run/fleet.key',
allowedKeyFiles: ['./run/gateway.key'],
kernelKeyFile: './run/fleet.kernel.pub'
})
await runtime.start()
kernel.allowKey(runtime.getKernelPublicKey())
await startGateway({
kernel,
port: 3000,
gatewayKeyFile: './run/gateway.key',
apps: [{ name: 'fleet', appDir, keyFile: './run/fleet.key' }]
})The runtime's Kernel key must be admitted before its controllers call the Kernel, through kernel.allowKey(), the Kernel's
auth.whitelist, or the trusted-keys directory. startGateway()'s apps reference lists the validation errors,
and @tetherto/mdk-app-runtime the runtime options. startGateway() throws
ERR_GATEWAY_EXTRA_PLUGIN_DIRS_UNSUPPORTED when passed extraPluginDirs.
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 app's context module
require('@tetherto/mdk-gateway/plugin') resolves, inside an app's controllers, to that app's own frozen context. The
controller reference shows a controller building its own client from it:
| Field | Type | Contains |
|---|---|---|
config | object | The mdk-app.yaml config defaults with the app's spec.apps[].config layered over them key-by-key, plus kernelKey, kernelBootstrap and the app's own kernelKeyPair, which is what createMdkClient(config) reads |
logger | object | The app runtime's logger, namespaced mdk:app:<name> |
onReady | function | Registers a callback that runs once the app runtime is serving, or straight away if it already is. Errors thrown inside it are caught and logged, not fatal |
Supplying per-app config
That config isn't declared in mdk-plugin.json. Defaults live under config in mdk-app.yaml, and the stack spec overrides
them per app with spec.apps[].config (createAppRuntime({ config }) from code):
spec:
apps:
- name: custom-metrics
package: ./apps/custom-metrics
config:
apiKey: ${METRICS_API_KEY}Build a lib/client.js from it once per app 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:
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:
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:
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)demo/controllers/summary.js is the smallest worked example of this fan-out, worth starting
from if you're authoring your own app.
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:
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 }))
)demo/controllers/history.js is the smallest worked example — a single-file version with an
optional per-device filter and a Kernel-unavailable fallback.
Both live and historical calls are network calls through the client — guard both the same way. An
unguarded pullTelemetry or pullWorkerTelemetry throws ERR_MDK_CLIENT_UNAVAILABLE straight through to the caller as an
unhandled 500 the moment a Worker or the Kernel drops; neither path degrades more gracefully than the other.
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 shows the pattern; return shape
is commandId, status, result, error.
Caching
Add a "cache" array of dot-path strings to a route to enable request-level caching. The key is the route id plus those values, so a route
whose answer depends on the caller adds an identity path such as _info.user.userId. On an auth: true route the
auth check runs before the cache lookup. A forced refresh with ?overwriteCache=true is honored only when the
authVerifier returned an identity whose perms array holds cache:overwrite; any other request uses normal cache behavior.
The manifest reference 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 covers the mechanism and the
handler's error behavior. backend/apps/agent is a shipping example — its message route
streams text/event-stream this way; see the agent Gateway-deployment guide for the consumer side.
Auth and permissions
Mark a route "auth": true and the Gateway refuses it with 401 ERR_AUTH_UNAUTHORIZED unless the authVerifier passed to
startGateway() returns an identity for the request. The verifier runs before body parsing, validation, the cache and your controller.
Its return value reaches the controller as req._info, so identity never comes from the request body. Routes without "auth": true
are served to any caller that passes the Host check. The Gateway's security model lists the rest of the
defaults.
The Gateway ships no identity layer. Supply one in the verifier:
const { validateToken } = require('./lib/my-identity-layer')
await startGateway({
kernel,
gatewayKeyFile: './run/gateway.key',
apps: [{ name: 'fleet', appDir, keyFile: './run/fleet.key' }],
// route is { id, method, path, plugin, permissions } from the manifest
authVerifier: async (request, route) => {
const token = request.headers.authorization?.replace('Bearer ', '')
if (!token) return false
const { email, permissions } = await validateToken(token)
if (!(route.permissions || []).every(p => permissions.includes(p))) {
throw Object.assign(new Error('ERR_AUTH_PERM_DENIED'), { statusCode: 403 })
}
return { user: { userId: email }, perms: permissions }
}
})The Gateway does not interpret the manifest "permissions" field itself; it hands it to the verifier as route.permissions.
The controller then reads the verified identity:
module.exports = async function protectedRoute (req) {
const { user, perms } = req._info
// Pass user and perms into Kernel yourself (for example as authPerms). Do not take them from req.body.
}The Gateway forwards that identity to the app runtime with the request, so req._info is set in the app's process too.
A controller's return value always goes out as 200, as JSON: it crosses from the app runtime to the Gateway serialized, so
return plain data (a Buffer arrives as { type: 'Buffer', data: [...] }; send bytes from a stream: true route). A thrown error is shaped by errorResponse: its status comes
from err.statusCode when it is an integer 400 or higher, and falls back to 400 for an ERR_* error and 500 for anything
else, which is a bug rather than a bad request. Attach .statusCode to get a specific
code, the way protectedRoute does here; the bundled agent app uses this same pattern for its 503, 404, and 409 responses. The
body is { statusCode, error, message }, and message carries the error's own text for ERR_* codes and for Fastify's own 4xx
errors (validation, content type, body size); any other message is replaced with the status text and logged.
Manifest validation errors
The app runtime validates the route table and every handler at startup, and the Gateway the route table, and each throws if anything is wrong — see the loader's error codes for the full list.
Next steps
- Try the live site backend example for a complete worked app with three routes: a live site overview, a historical series, and a command endpoint running under PM2 or Docker
- Build the minimal dashboard tutorial — end-to-end worked example of the single-app + controller pattern
- Read the demo app for the smallest complete worked example of both fan-out patterns above
- Understand how Workers declare their data via
mdk-contract.json— whatmdkClientreads andsendCommanddispatches - See the full manifest and controller reference
- Review the Gateway API and config
- Follow how a request travels and who Kernel admits
- Secure the site with the security blueprint