Deploy the agent behind the Gateway
Mount the conversational operator agent behind the Gateway as a chat API, and drive a session through an approval-gated write
Overview
@tetherto/mdk-app-agent mounts @tetherto/mdk-agent behind the Gateway as a chat API.
Enable the app for session, message, and approval routes: every write the agent proposes pauses for an operator's
decision. The agent itself still reaches fleet data the way any AI agent does, over an MCP server reachable at
agent.mcp.url: the standalone @tetherto/mdk-mcp process. Against a stack's mdk run mcp, the agent signs in as its own service
account. This app gives a human operator a chat surface to talk to it through.
This is one of two ways to run the agent. If you've already run it standalone, it's the same agent with HTTP on it, not a second product: the same provider, the same MCP client, the same approval gate, just reached over sessions instead of a REPL.
Prerequisites
- The Gateway is running
- A model is served on this machine, or reachable on another, because the app dials the provider rather than starting one
- An MCP tool server is reachable, so the agent has fleet tools to call:
mdk run mcpin this stack, or another stack's
The Gateway never starts a model. config.agent.provider is a URL it dials, so against a baseURL with nothing behind it
POST /agent/sessions still succeeds, and the first message's stream ends with an SSE event: error saying
"the assistant could not reach its model". Serving a model locally with QVAC covers the install, the first-run
download, and the flags a lazily loaded model needs.
Mount the app
Either:
A Select it during onboarding
Run mdk onboard and select mdk-app-agent from its app catalog.
mdk onboard's wizard supports creating a compliant mdk.yaml from scratch, interactively.
It installs @tetherto/mdk-app-agent from its public repository and writes its spec.apps entry with mount: /.
The app is not on npm, so onboarding installs github:tetherto/mdk-app-agent. The dependency onboarding records in
package.json carries no version or ref, so the commit is pinned by your lockfile rather than by the manifest.
mdk add app github:tetherto/mdk-app-agent writes the resolved commit into package.json as well. Commit the
lockfile: without one, a later install resolves whatever that repository's default branch points at. Installing from
GitHub needs CLI 0.13.1 or later. The 0.13.0 CLI installs the app by package name, which fails because it is not on
npm: on 0.13.0, leave the app out of the wizard and run mdk add app github:tetherto/mdk-app-agent after onboarding,
which writes the same spec.apps entry, mount: / included.
B Add it to an existing mdk.yaml
Already have one from a previous onboarding run? Add the app under spec.apps by hand, with mount: / and the model
provider, the MCP url, and the approval timeout under agent. The app resolves from
node_modules/@tetherto/mdk-app-agent, and it is not on npm, so install it from its repository first, from the project
directory:
npm install github:tetherto/mdk-app-agentCommit the lockfile, for the reason in the note under option A. For example, a standalone gateway carrying just this app, reaching across to the MVP site example's MCP tool server:
apiVersion: mdk/v1
kind: Stack
metadata:
name: agent-gateway
spec:
workers: []
gateway:
port: 3847
apps:
- name: agent
package: "@tetherto/mdk-app-agent"
mount: /
config:
agent:
provider: { kind: qvac, model: qwen3-4b, baseURL: http://127.0.0.1:11500/v1 }
mcp: { url: http://127.0.0.1:3101/mcp }
approvalTimeoutMs: 120000Keep mount: /. The agent UI calls /agent/sessions… at the Gateway root; without it the routes live
under /apps/agent/agent/sessions… and the UI gets 404.
1.2 Run it
Once the mdk.yaml names the app, either way, write the access policy before the first run:
mdk rbac initmdk run refuses a stack whose spec.rbac binds nobody, because the Kernel would boot with nobody able to administer it.
mdk rbac init writes the starter policy: an admin role, the CLI bound to it, and the Gateway and MCP server bound to
read policy. Step 4 of the security blueprint covers what it writes and how to change it.
Then run mdk run. It starts the app in its own runtime process and the Gateway forwards the agent routes to it.
A stack built for just this app declares no Workers, so mdk run boots a Kernel it never actually uses
(it only proxies chat to the MVP site's separate MCP server) alongside the app and the Gateway.
The agent routes are auth: true, so each one is checked against spec.rbac and needs an access token. A call without one
answers 401 ERR_AUTH_UNAUTHORIZED. Create the first operator with mdk user add <name> --admin while
the stack is running, then sign in at POST /auth/login and send the accessToken it returns as
Authorization: Bearer <token> (Gateway security model).
A missing config.agent block
answers 503 ERR_AGENT_UNAVAILABLE instead of failing to load.
Give the agent a service account
mdk run mcp checks every agent against spec.rbac, so the agent signs in to it as its own service account. Without
one, creating a session answers 503 ERR_AGENT_UNAVAILABLE: the MCP server at <url> answered 401. The
MVP site example's MCP tool server on 3101 checks no token: against it, skip this step.
2.1 Create the account
With the stack running, from the project directory:
mdk sa add operator-agentThis writes the agent's credential to .mdk/credentials/agents/operator-agent.json, readable by you only, and registers
its public key with the running Kernel.
2.2 Grant it the tools it may call
Bind the account to a role in mdk.yaml, beside the roles and bindings mdk rbac init wrote. An MCP tool generated from
an app route needs the route's resource, <app>/<path>, and verb, so this role lets the agent call every read route of
an ops app:
spec:
rbac:
roles:
- name: ops-viewer
rules:
- resources: [ops/*]
verbs: [get, list]
bindings:
- role: ops-viewer
subjects:
- kind: ServiceAccount
name: operator-agentApply the policy:
mdk rbac applyConnect an AI agent over MCP covers how a route maps to a resource and a verb.
2.3 Point the agent at the account
Add the credential under agent.mcp, with the stack's Gateway, where the account signs in. The app runs from the
project directory, so the path is relative to it:
config:
agent:
provider: { kind: qvac, model: qwen3-4b, baseURL: http://127.0.0.1:11500/v1 }
mcp:
url: http://127.0.0.1:3947/mcp
credential:
file: .mdk/credentials/agents/operator-agent.json
gatewayUrl: http://127.0.0.1:3847
strictToolGating: false
capability: mid
approvalTimeoutMs: 120000The agent sends an access token on every MCP request and renews it before it expires. Tools generated from app routes
carry no agent contract, so strictToolGating: false with capability: mid (or large) is what admits them.
For a quick test, agent.mcp.token takes a fixed token from mdk sa token operator-agent in place of credential. It
lasts 10 minutes, after which every tool call fails until the app restarts with a new one.
2.4 Restart and start the MCP server
Restart mdk run so the app reads its new config, then start the MCP server in a second terminal:
mdk run mcp # http://localhost:3947/mcpmdk run never starts it. It listens on spec.mcp.port, by default the Gateway's port plus 100.
Create a session and send a message
Use the port your mdk.yaml gave the gateway: 3847 in the example above. Note that
the MVP site's own gateway (3000) never carries the agent app; this is a separate gateway, reaching
across to the MVP site's MCP tool server on 3101.
Sign in first. The access token comes back in the response body; every call below carries it.
TOKEN=$(curl -s -X POST http://localhost:<port>/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"<name>","password":"<password>"}' | jq -r .accessToken)
curl -X POST http://localhost:<port>/agent/sessions \
-H "Authorization: Bearer $TOKEN"
# {"sessionId":"..."}
curl -N -X POST http://localhost:<port>/agent/sessions/<id>/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"text":"how many miners are on the site?"}'The response streams as text/event-stream. A read-only question ends in tool_call, tool_result, token, and done
events, each stamped with the turn's turnId and a monotonic seq.
Approve a write
A write action pauses the turn instead of running it:
event: pending_approval
data: {"type":"pending_approval","name":"act_device","args":{"ref":"whatsminer-0","action":"reboot"},"approvalId":"..."}Decide it from the paused stream's approvalId:
curl -X POST http://localhost:<port>/agent/sessions/<id>/approvals/<approvalId> \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"approved":true}'Approving resumes the same stream: the tool runs for real, and the turn continues to its token and done events. Rejecting
resolves to false, and the write never runs. Letting the approval window (approvalTimeoutMs) expire blocks the write the same
way. With @tetherto/mdk-agent 0.13.1 and an mdk-app-agent build that depends on it, the tool_result then reads
(no operator decision — approval expired, not executed), and the agent says no approval arrived in time. Earlier builds,
including the 0.13.0 agent, report an expired approval as an operator rejection. Disconnecting while an approval is pending
ends the turn: the write never runs, and the stream sends no tool_result.
Troubleshooting
- Creating a session answers
503 ERR_AGENT_UNAVAILABLE: the MCP server at <url> answered 401: set mcp.credential to the agent's service account. The MCP server checks agents againstspec.rbacand the agent sent no token. Give the agent a service account underagent.mcp.credential - Creating a session answers
503 ERR_AGENT_UNAVAILABLE: ERR_IDENTITY_ASSERTION_INVALID: unknown service account. The Kernel atagent.mcp.credential.gatewayUrlhas no such account. Runmdk sa add <name>against this stack, and checkgatewayUrlis this stack's Gateway - Creating a session answers
503 ERR_AGENT_UNAVAILABLE: … answered 401: its token was refused. The token was issued by another stack, oragent.mcp.tokenhas expired. PointgatewayUrlat the Gateway of the stackmdk run mcpbelongs to, or replace the fixed token withcredential - Sessions work and
agent.mcp.credentialis set, but the agent never calls a tool.mdk run mcplists only the tools the account's roles allow, and no rule grants it a route. Bind it to a role inspec.rbac, runmdk rbac apply, then restartmdk run: the agent reads its tool list once, when the first session creates it - Sessions and messages work, but the agent never calls a tool.
agent.mcp(or itsurl) is missing from the config — how to fix it is in the app's troubleshooting entry - The agent never calls the tools your app's routes produce. Under the default
strictToolGating, the agent admits only tools that carry an agent contract, and tools generated from app routes carry none, so it skips them.mdk-app-agentbuilds after 0.13.0 logagent skipped N MCP tool(s)with each tool and reason when the agent is created. To admit them, setstrictToolGating: falseandcapability: mid(orlarge) underagent, for a model strong enough to use tools without a contract - Every agent route answers
401. No access token on the call, or an expired one. Sign in again atPOST /auth/loginand resend withAuthorization: Bearer <token> - The agent routes answer
404. The app entry has nomount: /, so its routes are served under/apps/<name>. Addmount: /to itsspec.appsentry - Every other failure (
503,404,409,400) maps to a specific cause and fix in the app's error reference npm cifails to resolve a stack that mounts only this app.@tetherto/mdk-app-agentdeclares@tetherto/mdk-agentas a required dependency, not an optional peer, so the agent package must be installed alongside it fornpm cito resolve.
Next steps
- Read the agent app's route reference: session, message, and approval routes, plus the manifest's
setupfields - Get the agent app: its public repository, and why the onboarding catalog cannot install it yet
- Understand the underlying agent: the model and its fleet tools
- Submit and approve write actions from a React app, for the UI-driven shape of this same approval gate