See an app serve your data
[⏱️ ~5 min] Scaffold a Worker and an app with the CLI, run the stack, and watch the app answer an HTTP request with live fleet data
If Kernel, Gateway, Worker, or app are unfamiliar, read terminology first.
Overview
The Gateway serves no data routes of its own. It routes HTTP requests to apps, so every endpoint your application reads comes
from an app your stack declares. This tutorial scaffolds one with the mdk CLI and runs it so you can watch that happen once
before writing your own.
What you'll have at the end:
- A mock miner Worker with one seed device, scaffolded by
mdk create worker - An app with one read-only route, scaffolded by
mdk create appand running in its own process - A Gateway on
:3847forwardingGET /apps/fleet/api/fleet/summaryto that app - The JSON body of that request printed to your terminal, counting the Workers and devices the Kernel knows about
Prerequisites
- Node.js >=24 (LTS)
- npm 11 (< 12)
- The
mdkCLI installed - Outbound UDP to the HyperDHT bootstrap nodes
jq, to read the access token out of the sign-in response
Create a project
mkdir fleet-demo && cd fleet-demoWrite a minimal mdk.yaml:
apiVersion: mdk/v1
kind: Stack
metadata:
name: fleet-demo
spec:
workers: []
gateway:
port: 3847
apps: []mdk onboard writes the same file interactively; this tutorial keeps it to the fields it uses.
Scaffold a Worker and an app
mdk create worker demo-miner
mdk create app fleetEach command copies a template, links the package into the project's package.json, runs npm install, and adds an entry to
mdk.yaml: the Worker under spec.workers with a mock: true seed device, the app under spec.apps.
Run the stack
mdk rbac init
mdk runmdk rbac init writes the roles and bindings spec.rbac needs, binding the CLI's own account to an admin role and granting the
app the Kernel access its manifest requests. Without them mdk run refuses to boot a Kernel nobody could administer.
mdk run boots the Kernel, the Worker and its mock device, one runtime process for the app, then the Gateway. Leave it running.
No app route is open, so create a user from a second terminal, sign in, and call the route with the access token:
mdk user add admin --admin # prompts for a password; the Kernel must still be running
TOKEN=$(curl -s -X POST http://localhost:3847/auth/login \
-H 'content-type: application/json' \
-d '{"username":"admin","password":"<the password you set>"}' | jq -er .accessToken)
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:3847/apps/fleet/api/fleet/summaryExpected output (ids vary):
{"ok":true,"kernelConnected":true,"workerCount":1,"deviceCount":1,"workers":[{"workerId":"...","devices":1}]}A workerCount of 0 means the Worker has not registered yet; call again after a few seconds. That JSON came from a route the
Gateway did not have until the stack declared an app that serves it.
Read the files that produced it
The app is a directory with a manifest, a route table and a controller:
apps/fleet/
├── mdk-app.yaml # id, version, displayName, permissions, config defaults
└── plugin/
├── mdk-plugin.json # the route table the Gateway reads
├── lib/client.js # the app's MDK client
└── controllers/summary.js # the handlerplugin/mdk-plugin.json declares the route: an id, the handler path, an http block giving the method and path, and
"safety": "read-only", which is what lets mdk run mcp offer the route to an agent as a tool.
plugin/controllers/summary.js is the handler. Every controller is async function (req) and returns a plain object, which
is sent back as JSON. It never touches a response object:
const mdkClient = require('../lib/client')
module.exports = async function summary (req) {
const resp = await mdkClient.listWorkers()
const workers = (resp && resp.workers) || []
// count devices across every Worker
}mdkClient, built once for the app in plugin/lib/client.js from require('@tetherto/mdk-gateway/plugin'), is the same
protocol client used everywhere else in MDK. Aggregation lives here, in the app. Workers are structurally single-device,
so a Worker can only ever answer for one device; combining them into a fleet view is the app's job.
The route answered because --admin bound admin to a role holding wildcards. Every app route is checked against spec.rbac:
a request with no token answers 401, and a signed-in caller with no rule over fleet/api/fleet/summary answers 403.
Narrowing a user down to the resources they should reach is covered in
auth and permissions.
What just happened
- Scaffolding copied a Worker template (a firmware-v3 miner simulator) and an app template into
workers/demo-minerandapps/fleet, and registered both inmdk.yaml. - Kernel started fail-closed. Each caller, the app runtime included, published its key to
.mdk/trusted-keys/, which is how the Kernel admits it. Admission only lets a caller connect: every request past sign-in carries an access token the Kernel checks againstspec.rbac. - Worker started with its mock device and published its key to
.mdk/keys/, where the Kernel discovered it. - App runtime started
apps/fleetin its own process, loaded its route table and controller, and accepted calls only from the Gateway's key. - Gateway signed
adminin at/auth/login, checked the access token and the rule overfleet/api/fleet/summary, then read the app'smdk-plugin.json, mounted its route under/apps/fleet, and forwarded your request to the app runtime over HRPC. - The controller called
listWorkers()on the Kernel and returned the counts as JSON.
Cleanup
Stop mdk run with Ctrl+C, then remove the project:
cd .. && rm -rf fleet-demoNext steps
- Write your own routes by building an app: the manifests, controllers, reading hardware, and sending commands
- Put a page in front of your route by building a minimal single-page dashboard: the same shape with a React front end
- Integrate your own hardware by building a third-party Worker
- See the whole stack instead of one route by running a mining site end to end