Tool channel
The wire contract between the brain and a worker: who dials whom, the four lifecycle frames, what counts as an answer to each, what happens when the answer never comes, and what the two sides do when they turn out to speak different versions.
This page is written once, on purpose
Two features meet here. specs/006-runtime-pipeline (T000) provisions the worker; specs/007-worker-sandbox (T001, T002) is what runs inside it. Both task lists point at this file and neither restates it. A contract written twice drifts, and it drifts silently — the side waiting for a frame hangs instead of refusing, and a hang is diagnosed in hours where a refusal is diagnosed in seconds.
Lifecycle: Deploy → Use → this channel → Destroy.
Scope — and what is deliberately elsewhere
This page covers channel 3 only (the mind↔worker tool channel), and within it only the lifecycle: connection, handshake, liveness, teardown, versions.
| Here | Not here |
|---|---|
| The connection's direction, framing and authentication | The user chat stream (channel 1) and the provision queue (channel 2) — Use → the protocol |
initialize · ready · heartbeat · release | tools/list · tools/call · notifications/progress · resource_link — Use → on the wire |
| Identifier correspondence and the one-connection rule | Which tool runs where — Use → the tool split |
sampling and the other reverse capabilities | Profiles, the Job manifest, the security checklist — Deploy |
| Timeouts, close codes, version refusal | Session states and the teardown order — Destroy |
The shape of the connection
The worker dials out. Nothing ever dials in.
worker pod api worker-gateway
│ 1. HTTPS upgrade → CONTROL_URL
│ Authorization: Bearer WORKER_TOKEN
│ Sec-WebSocket-Protocol: agentfy.worker.v1
│ ─────────────────────────────────────────────▶
│ 101 Switching Protocols
│ ◀─────────────────────────────────────────────
│ 2. initialize (host → worker, MCP client → server)
│ ◀─────────────────────────────────────────────
│ initialize result
│ ─────────────────────────────────────────────▶
│ 3. notifications/initialized
│ ◀─────────────────────────────────────────────
│ 4. agentfy/ready (worker → host)
│ ─────────────────────────────────────────────▶
│ 5. tools/list (host → worker) ← the session becomes `running`
│ ◀─────────────────────────────────────────────
│ … tools/call · progress · agentfy/heartbeat …
│ n. agentfy/release (host → worker)
│ ◀─────────────────────────────────────────────
│ release result, then close 4005, exit 0
│ ─────────────────────────────────────────────▶The direction is not a detail. Out of the worker is reachable; into it is not, by design — the NetworkPolicy is default-deny, the pod has no Service, no Ingress and no stable address, and it is rescheduled without notice. The api therefore never holds a pod IP. Everything else on this page follows from that one fact: the host cannot re-establish a lost connection, so only the worker retries, and both sides need timers rather than the ability to poke each other.
Roles, which are not the same as who connected
| TCP / WebSocket | MCP | |
|---|---|---|
| worker | client — it initiates | server — it owns the tools |
| api worker-gateway | server — it accepts | host / client — it discovers and calls |
The worker is the TCP initiator and the MCP server at the same time. That inversion is the whole reason a stock MCP transport does not fit and this one is written down: with Streamable HTTP the host must reach the server, which is exactly the network posture we refuse.
Framing
- One JSON-RPC 2.0 message per WebSocket text frame, UTF-8, no batching, no fragment reassembly above the WebSocket layer.
- A frame that is not valid JSON-RPC is answered
-32700/-32600and counted; ten malformed frames on one connection close it with4006. A parser that silently discards garbage is how a version skew becomes a hang. - WebSocket ping/pong every 30 s in both directions. It keeps intermediaries from timing the socket out, and it proves nothing about the process — see heartbeat.
Identity — one identifier, checked three ways
AgentRuntimeSession.id is the only session identifier on this channel. The MCP session id is that value, verbatim — not a second id with a mapping table. A mapping is a second thing to get wrong, and the audit trail has to join on something.
Three routes carry that value, and only two of them can be checked at the handshake:
| Route | Set by | On the wire? | Trusted? |
|---|---|---|---|
sub of WORKER_TOKEN (Bearer, on the upgrade) | the api, at mint time | yes | yes — this is the authority |
SESSION_ID, echoed in the initialize result | the manifest builder | yes | no — compared against the token, never believed |
the session label on the k8s Job | the manifest builder | no | not checkable here at all |
The label never reaches this channel. It is a cluster-side handle — how the reaper deletes the right Job, how an operator joins a pod to a session row after the fact. A contract that claimed the handshake "requires all three to agree" would be claiming something the handshake cannot do. It compares the two that travel, and that is enough: the token is the authority, and the environment variable is the only one of the three a misconfigured manifest can get wrong in a way the channel would ever see.
The host derives sessionId from the token and refuses if the sessionId the worker echoes in its initialize result does not match: -32002 SESSION_MISMATCH, close 4002, session → failed. A pod that disagrees with its own token is a misconfigured manifest, and it must fail loudly at second zero rather than serve one tool call for the wrong session.
One live connection — and how a reconnect gets through it
A session has one live channel at a time. Written as the flat rule "a second dial for a live session is refused", that would annul the reconnect window completely, and precisely in the case it exists for: the worker abandons a half-open socket at heartbeatGraceSeconds and the host only gives up one interval later, so the worker's first retry lands inside the window where the host still believes the old channel is alive. It would be refused, and refused finally. The recovery path would never once run.
So the dial carries two more values, and they separate "the same process coming back" from "a second process shadowing a live one":
| Field | Where | What it is |
|---|---|---|
instanceId | _meta.agentfy of the initialize result | minted once when the worker process boots; constant across every reconnect of that process |
attempt | _meta.agentfy of the initialize result | 0 on the first dial, +1 on each redial by that process |
- Same
instanceId, higherattempt→ accepted. The host closes the older socket with4009superseded and binds the session to the new one. Nothing is lost: the sticky state lives in the process and the process never died. The host re-runstools/listand replaces the registered toolset rather than adding a second one. - Same
instanceId,attemptnot higher → refused4002. A stale retry arriving late must not displace a newer connection. - Different
instanceId→ refused4002. That is a second process holding this session's token — a Job that produced two pods, or a replayed token — and it is the case the rule exists to stop.
If the worker ever finds itself holding two open sockets, its own newest successful initialize is authoritative and it closes the older one itself rather than serving calls on both.
The four frames
Everything on the channel besides these four is work, and work is specified in Use. These four are what makes the channel exist, stay honest, and end.
| Frame | Direction | Kind | Answer | If the answer never comes |
|---|---|---|---|---|
initialize | host → worker | request | initialize result | worker retries, then exits 75; host fails the session |
agentfy/ready | worker → host | notification | the host's tools/list | worker retries, then exits 75; host fails the session |
agentfy/heartbeat | worker → host | request | heartbeat result | worker retries, then exits 75; host reaps the session |
agentfy/release | host → worker | request | release result | host deletes the worker at the grace deadline |
Custom methods are namespaced agentfy/ so they can never collide with a method MCP adds later. initialize is stock MCP and is used as MCP defines it.
Every deadline and every exit code lives in one table, and nowhere else. The four sections below name the budget that governs a wait; they do not repeat its value. Two copies of a timeout are two timeouts, and the second one is wrong the day somebody tunes the first.
1. initialize — host → worker
Sent by the host, immediately after the upgrade succeeds. In MCP initialize is a client → server request, and here the host is the client; the worker answers it.
Authentication is not in this frame. WORKER_TOKEN travels in the Authorization header of the WebSocket upgrade, so an unauthenticated dial is refused with HTTP 401 before a single JSON-RPC byte moves and never becomes an MCP session at all. This departs from the literal wording of Use → the handshake; see divergences.
// host → worker
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18",
"clientInfo": { "name": "agentfy-worker-gateway", "version": "<api image tag>" },
"capabilities": { }, // ← empty. no sampling, no elicitation, no roots
"_meta": { "agentfy": {
"sessionId": "<AgentRuntimeSession.id>",
"channelVersion": "1.3.0",
"profile": "light",
"budgets": { "handshakeSeconds": 10, "readySeconds": 30, "readyAckSeconds": 15,
"heartbeatIntervalSeconds": 10, "heartbeatGraceSeconds": 30,
"idleSeconds": 90, "maxExecSeconds": 900,
"releaseGraceSeconds": 30, "reconnectWindowSeconds": 60 } } } } }
// dialSeconds is deliberately absent — it governs the wait BEFORE this socket
// existed, so the worker cannot observe it. It is a profile value. See #deadlines.// worker → host — the answer
{ "jsonrpc": "2.0", "id": 1, "result": {
"protocolVersion": "2025-06-18",
"serverInfo": { "name": "agentfy-worker", "version": "<worker image tag>" },
"capabilities": { "tools": { "listChanged": true }, "logging": { } },
"_meta": { "agentfy": { "sessionId": "<echoed>", "channelVersion": "1.1.0",
"instanceId": "<minted once at process boot>", "attempt": 0 } } } }The worker follows its result with the stock notifications/initialized; only then may the host send tools/list.
Budgets arrive in the frame, not only in the environment. The pod already gets ceilings from its manifest; sending them again here means the two can be compared, and a worker whose env disagrees with the channel logs the difference and obeys the smaller of the two. A ceiling that exists in one place only is a ceiling nobody can check.
On silence — and these are two different waits, not one. Both sides arm a timer before they wait; values are in Deadlines.
- Before this socket exists. The host arms
dialSecondsthe moment it creates the worker. That window covers scheduling the pod, pulling the image and starting the process — the cold start Deploy prices separately, and a Browser profile that bundles Chromium needs far more of it than a Light one. It is nothandshakeSeconds. Giving one budget to both waits sets the host's patience for a whole cold start to the patience it has for one round trip, and every session dies before its pod is up. - Worker — no
initializewithinhandshakeSecondsof the socket opening: close, retry inside the reconnect window, then exit75. The host accepted the connection and then went quiet; that is a host-side fault and it may well pass. - Host —
notifications/initializednot received within2 × handshakeSecondsof the socket opening — twice the worker's budget, so the worker always gives up first and gets to retry into a session that still exists. (The wait before this one isdialSeconds, above; the wait after it isreadySeconds, next section.) On expiry: close4003, session →failed(failureReason: handshake_timeout), teardown, and no retry (backoffLimit: 0).
Two words, two events, two budgets. The handshake is complete at notifications/initialized — where MCP itself ends initialization, and what handshakeSeconds measures. The session is running when tools/list has returned, which is what readySeconds measures, starting exactly where the handshake ended. Collapsing the two into one deadline is what makes the second unreachable, and the deadline table says why at length. Between the worker's creation and running the host is therefore never unarmed and never doubly armed: three consecutive waits, no gap and no overlap.
Each wait is therefore bounded on both sides independently, and no side is allowed to wait on the other's good behaviour.
2. agentfy/ready — worker → host
A notification, sent by the worker, meaning exactly: my executors are constructed, my workspace is mounted, I will accept tools/call. It carries the allowlist the worker actually resolved, so a disagreement about the toolset surfaces before the first call rather than as a missing tool later.
// worker → host
{ "jsonrpc": "2.0", "method": "notifications/agentfy/ready", "params": {
"sessionId": "<id>", "tools": ["exec", "file"], "workspace": "/workspace",
"bootMillis": 812 } }ready and running are two events, not one word. The worker declares readiness; the host declares the session running only once tools/list has returned and the toolset is registered into the live session. Between them sits a real window in which the worker is willing and the brain cannot yet call it.
The answer is the host's tools/list. A notification has no JSON-RPC response, so the discovery call is what acknowledges it. Without that rule the worker's most likely failure is to sit ready forever with nobody listening.
On silence — both directions, because a worker that never announces itself is as real a failure as a host that never answers one that did:
| Who notices | Threshold | What happens |
|---|---|---|
| Worker | no tools/list after agentfy/ready for readyAckSeconds | treats the host as absent → reconnect window → exit 75 |
| Host | the session not running within readySeconds of notifications/initialized — agentfy/ready never came, or tools/list went unanswered | close 4003, session → failed (failureReason: ready_timeout), teardown, no retry |
readySeconds covers everything between the handshake ending and the session being usable: constructing executors, mounting the workspace, and answering tools/list. So it is a profile value like the rest — a Browser profile launching Chromium needs more of it than a Light profile that only has to open a shell — and it is the only host deadline armed across that stretch. Nothing shorter may overlap it, or the profile it exists for is the profile it kills.
3. agentfy/heartbeat — worker → host
A JSON-RPC request, not a WebSocket ping, sent by the worker every heartbeatIntervalSeconds.
// worker → host
{ "jsonrpc": "2.0", "id": 42, "method": "agentfy/heartbeat", "params": {
"sessionId": "<id>",
"idleSeconds": 37, // since the last tools/call FINISHED — the idle clock
"execSeconds": 512, // since the process started — the ceiling clock
"activeToolCalls": 0,
"workspaceBytes": 18452113,
"channelVersion": "1.1.0" } } // the EFFECTIVE version, as the worker computed it
// host → worker — the answer
{ "jsonrpc": "2.0", "id": 42, "result": {
"continue": true, "idleBudgetSeconds": 90, "execBudgetSeconds": 900 } }Why a request and not ping/pong. Ping/pong is answered by the WebSocket library beneath the application: a worker whose event loop is wedged, whose executors are dead, or whose workspace has vanished still pongs happily. That is precisely the zombie the heartbeat exists to catch. A request is answered by the code that would also have to answer a tool call, and it can carry a payload — which the next rule needs.
A heartbeat is a liveness signal, never an activity signal. It says I am alive; it does not say I am working. The idle clock is reset by a finished tools/call and by nothing else. Both sides read the same number — idleSeconds, measured once, in the worker — so the reaper and the worker can never disagree about who is idle. This corrects a real contradiction in the worker docs: pages that let a heartbeat reset the idle timer describe a session that is never reaped, because a healthy idle worker heartbeats forever.
The host may shorten a budget in the answer and may set continue: false, which means stand down now — the worker then runs its own teardown as if it had been released. The host may never raise execBudgetSeconds above maxExecSeconds; the worker enforces its own ceiling regardless of what it is told, because a ceiling that can be lifted over the wire is not a ceiling.
On silence.
| Who notices | Threshold | What happens |
|---|---|---|
| Worker | no answer to a heartbeat within heartbeatGraceSeconds (three missed) | the control connection is considered lost → reconnect window → exit 75 |
| Host | no heartbeat request for heartbeatGraceSeconds + heartbeatIntervalSeconds | session → failed, teardown runs, worker deleted — Destroy → zombie protection |
The host's threshold is one interval wider than the worker's on purpose: a worker that can still reach the host should always be the one to notice first, so the ordinary case is a clean self-exit rather than a forced delete.
4. agentfy/release — host → worker
A request, sent by the host, when the session is done: the reaper's idle window expired, the brain decided the work is finished, a hard ceiling was hit, or the session failed.
// host → worker
{ "jsonrpc": "2.0", "id": 77, "method": "agentfy/release", "params": {
"sessionId": "<id>", "reason": "idle", "graceSeconds": 30 } } // releaseGraceSeconds
// reason ∈ idle | explicit | deadline | failed
// worker → host — after flushing, before closing
{ "jsonrpc": "2.0", "id": 77, "result": {
"accepted": true, "outputsFlushed": true,
"outputs": [ { "type": "resource_link", "uri": "store://art_88" } ] } }The answer is the release result, and it is not an acknowledgement — it is the report. The worker sends it only once it has stopped its executors and flushed its outputs, and it carries whether that flush succeeded. A bare "ok" would tell the host nothing it needs, because the one thing the host has to record is whether anything was lost. The worker then closes with 4005 and exits 0.
graceSeconds is what makes the teardown order enforceable.Destroy requires outputs to reach system/filebefore the worker is deleted. That step needs a bounded window, and this is it: the host waits at most graceSeconds, then deletes the worker anyway and records outputsFlushed: false on the session. Data that could not be flushed is a fact worth having; a host that waits forever for a wedged worker is a leak.
On silence — both directions, in the same shape as the other three frames:
| Who notices | Threshold | What happens |
|---|---|---|
| Host | no release result within releaseGraceSeconds | delete the worker anyway, record outputsFlushed: false on the session |
| Worker | no release ever arrives, because the host is gone | its own idleSeconds and maxExecSeconds timers end it — and if the channel is what died, the reconnect window then exit 75 |
Neither side's teardown depends on the other being alive. Release is a courtesy that makes the teardown orderly and its outputs recoverable; it is never the only way out.
In flight. The host does not send release while a tools/call is outstanding unless reason is deadline or failed; on those the worker cancels its executors and still attempts the flush.
There is no done frame. Destroy mentions the LLM emitting a done hint — that is a hint to the brain, which the brain turns into a release with reason: "explicit". Nothing on this channel is called done.
Deadlines and exit codes
Every wait on this channel is in this table, and every number is in it once. A frame section above names the budget that governs a wait; the value lives here. Two copies of a timeout are two timeouts, and the second one is wrong the day somebody tunes the first.
Defaults are for the Light profile and every one of them is overridable per RuntimeProfile. None of them is a measurement — nothing has run yet.
| The wait | Whose timer | Budget · default | Starts at | On expiry |
|---|---|---|---|---|
| the pod dials home | host | dialSeconds · 120 s | the worker is created | session → failed (dial_timeout), teardown, no retry |
initialize arrives | worker | handshakeSeconds · 10 s | the socket opens | close → reconnect window → exit 75 |
| the MCP handshake completes | host | 2 × handshakeSeconds · 20 s | the socket opens | close 4003, session → failed (handshake_timeout), no retry |
the session reaches running | host | readySeconds · 30 s | notifications/initialized | close 4003, session → failed (ready_timeout), no retry |
tools/list arrives | worker | readyAckSeconds · 15 s | agentfy/ready is sent | close → reconnect window → exit 75 |
| a heartbeat is answered | worker | heartbeatGraceSeconds · 30 s | the heartbeat is sent | reconnect window → exit 75 |
| a heartbeat arrives | host | heartbeatGraceSeconds + heartbeatIntervalSeconds · 40 s | the previous heartbeat | close 4004, session → failed, teardown |
agentfy/release is answered | host | releaseGraceSeconds · 30 s | release is sent | delete the worker anyway, record outputsFlushed: false |
| a lost channel comes back | worker | reconnectWindowSeconds · 60 s | the channel is declared lost | flush what is possible, exit 75 |
| the worker stops working | either | idleSeconds · 90 s | the last tools/call finished | the reaper releases the session |
| the worker runs too long | either | maxExecSeconds · 900 s | the process started | hard stop; activeDeadlineSeconds is the cluster's backstop, and it is not the same number — see below |
dialSeconds is the one that is easy to get wrong, and it is the reason it has its own row. It covers a cold start — scheduling, image pull, process start — and none of that is a round trip. A host that gave this wait the same budget as a handshake would fail every session before its pod was up, and a Browser profile bundling Chromium by a wide margin. It also appears in no frame: the worker cannot observe a window that closes before its socket exists, so it is a profile value only.
The host's three startup waits are consecutive, and each begins where the last one ended.dialSeconds runs from the worker's creation to the socket; handshakeSeconds from the socket to notifications/initialized; readySeconds from there until the session is running. They do not overlap, and that is a rule rather than an observation. An earlier draft ended the handshake wait at "agentfy/ready has arrived and tools/list has returned" — which put a ten-second timer and a thirty-second timer on intervals that shared an end. The tighter one always wins, so every worker needing more than ten seconds to build its executors died at ten with handshake_timeout, and the budget written for slow executors could never be reached on the profile it was written for. Overlapping deadlines do not add patience; the smallest one is the only one that exists.
And at the one seam both sides watch, the host is deliberately slower. The worker's handshakeSeconds and the host's 2 × handshakeSeconds both start at the socket, and the doubling is the point: the worker has to notice a silent host first, because it is the only side that can dial again. Fire them together and the host marks the session failed and tears it down in the same instant the worker decides to retry — so the retry arrives at a session that no longer exists. That is the one-connection defect one layer up, and it takes the same answer: the side that can still act notices first. It is why the heartbeat pair is asymmetric too.
What a re-dial does to those waits, since the asymmetry now guarantees there can be one. A worker that gives up on a silent host retries into a session the host still holds. That re-dial is bound to the same sessionId under the one-connection rule, and the host re-arms handshakeSeconds and readySeconds against the new channel — otherwise the reconnected session would die on a clock that started on a socket which no longer exists. What is not re-armed is the outer bound: the host holds dialSeconds + 2 × handshakeSeconds + readySeconds from the worker's creation as the single answer to "this session has never once become usable", and re-dialling does not extend it. Once the session has reached running even once that bound is spent, and the reconnect window governs from then on.
Three constraints the profile catalog has to assert, because nothing on this page can:
idleSeconds < maxExecSeconds— otherwise k8s kills a warm session part-way through the idle window it was promised. See divergence 6.dialSeconds > 0, with a value drawn from the profile's own image rather than the Light default.activeDeadlineSeconds ≥ dialSeconds + maxExecSeconds. Kubernetes countsactiveDeadlineSecondsfrom the Job's start, so it includes the whole cold start;maxExecSecondscounts from the process's start. Deploy's illustrative manifest sets them equal, which was right while there was one budget and is wrong now that the cold start has its own. Set them equal and the cluster kills a Browser session roughly a pull-and-launch early — and it kills it as an unexplained connection drop, which is the one failure this page works hardest to eliminate.
Exit codes
The worker's exit code says which kind of failure it was, and therefore whether anyone should retry.
| Exit | Name | Means | Reached from |
|---|---|---|---|
0 | — | released normally | an accepted agentfy/release, close 4005 |
75 | EX_TEMPFAIL | the control plane went quiet and did not come back | every silence deadline above, after the reconnect window expires; close 4004 |
77 | EX_NOPERM | the token or the session was refused | close 4002 |
78 | EX_CONFIG | a retry changes nothing: the two sides cannot agree on a version or a frame format, or this worker could not bring itself up in time | HTTP 426, close 4001, 4003, 4006 |
Silence is 75, disagreement is 78. The distinction is the whole point: 75 says try this again and it may well work, 78 says nothing will change until somebody deploys something different. A page that answered "the host went quiet" with EX_CONFIG would send an operator looking for a version skew that is not there.
Losing the connection
The host cannot dial the worker, so only the worker reconnects, and only within a bound.
- The socket drops, or the worker's own grace timer fires.
- The worker retries
CONTROL_URLwith exponential backoff (1 s, 2 s, 4 s… capped at 10 s) for at mostreconnectWindowSeconds. Executors keep running; sticky state is untouched — the process never died, so cwd, env, background processes and the browser context survive. - Each retry carries the process's
instanceIdand an incrementedattempt. A successful reconnect is a freshinitializebound to the samesessionId, and the host lets it displace the old channel under the one-connection rule — closing the older socket with4009rather than refusing the new one. This is the step that makes the window real: the worker gives up on a half-open socket one interval before the host does, so its first retry necessarily arrives while the host still believes the old channel is alive. A flat "one connection per session" would refuse exactly that retry, finally, and this section would never run. - The window expires → the worker flushes what it can and exits
75(EX_TEMPFAIL).
A close code decides whether retrying is even allowed.
| Close | Meaning | Worker retries? |
|---|---|---|
4001 | channel or MCP version not supported | no — exit 78 |
4002 | session mismatch, token invalid or expired, duplicate connection | no — exit 77 |
4003 | handshake deadline missed | no — exit 78 |
4004 | heartbeat lost (host side) | no — exit 75 |
4005 | released, normal | no — exit 0 |
4006 | too many malformed frames | no — exit 78 |
4008 | host draining (api replica rolling) | yes, within the window |
4009 | superseded — this socket was replaced by a newer dial from the same process | no, and nothing failed: the newer channel is already serving |
| any transport-level drop | unknown | yes, within the window |
A rolling api deploy closes 4008 and the worker rides it out. Every other refusal is final, because retrying a refusal a version or a token caused only burns the cluster and hides the cause.
sampling is off — and here is where
The worker holds no model credential. A worker that could ask the host to run a model would be borrowing the brain's credential through the wire, which is exactly the boundary the worker exists to draw. Two more reverse capabilities go with it: elicitation (a server asking the user a question — the worker must never reach a human) and roots (a client exposing its filesystem to the server).
Three places, and they are not redundant.
| # | Where | Enforced by | Stops |
|---|---|---|---|
| 1 | the host's initialize request | capabilities: {} — sampling, elicitation and roots are absent | a conforming worker from ever forming the request; MCP forbids a server using a capability the client did not declare |
| 2 | the host's transport, before dispatch | any inbound request whose method begins sampling/ → -32003 SAMPLING_DISABLED, audited on the session. elicitation/ and roots/ → -32004 CAPABILITY_NOT_NEGOTIATED | a non-conforming or compromised worker. This is the load-bearing one: it depends on nothing the worker does, and it lives in one place rather than one per handler |
| 3 | the worker's transport, on the way out | the same guard on outbound frames | a bug in an executor from even forming the request, and makes the refusal visible in the worker's own logs |
Layer 2 is the guarantee; 1 and 3 are how a correct system never reaches it. Note what is not on the list: a check inside each executor. That is the shape that rots — it can be forgotten in one executor and nobody notices, because the tool still works.
The version rule
The worker image and the api are built, tagged and rolled out separately. They will not always agree, and that is normal rather than exceptional. The rule exists so a disagreement ends in a refusal with two version numbers in it, never in a wait.
Three levels, each with its own refusal
| Level | Where it is stated | What a mismatch does |
|---|---|---|
| Channel major | WebSocket subprotocol on the upgrade: agentfy.worker.v1 | The host answers HTTP 426 Upgrade Required, listing the majors it speaks in Sec-WebSocket-Protocol. No socket opens, no JSON-RPC exists. Worker logs both lists and exits 78. |
| MCP protocol version | protocolVersion in initialize and its result | The worker answers with the nearest version it supports. If the host cannot speak it, the host closes 4001 carrying both values. No silent downgrade — a side that quietly pretends to speak a version it does not is how a field goes missing and a frame goes unanswered. |
| Channel minor | _meta.agentfy.channelVersion, semver, sent both ways | Permitted inside a major. The effective version is the lower of the two, and both sides compute it — see below. Each side then uses only features at or below it. |
Nobody announces the effective minor, because nobody can
The obvious wording — "the host states the effective version in the initialize result" — is wrong on this channel, and wrong in a way worth spelling out. The initialize result is sent by the worker, not the host; there is no frame in which the host could announce anything between the result and tools/list. So the rule is arithmetic, not an announcement:
- The host's
initializerequest carries the host'schannelVersion. The worker's result carries the worker's. - After the result, both sides hold both numbers, and both compute
min(host, worker). Nothing needs to be told to anybody. - Every
agentfy/heartbeatcarries the effective version as the worker computed it. If it differs from the host's own arithmetic the host refuses-32001 CHANNEL_VERSION_MISMATCHand closes4001. A negotiation that only two private calculations agree on is a negotiation nobody checked; this one is re-checked every ten seconds, and it fails as a refusal rather than as a field that quietly went missing.
And one frame is necessarily exempt. The host sends initialize before it can know the worker's minor, so "never send a field the other side did not promise" is unsatisfiable there. That frame is therefore restricted to what every minor of the channel major guarantees — which is what a major means — and the compatibility contract for it runs the other way:
- The host's
initializerequest carries only major-baseline fields. Anything a later minor added goes in a frame sent after the result, never in the first one. - A worker on an older minor ignores unknown
_meta.agentfykeys rather than refusing._metais MCP's extension point and this is exactly what it is for. Refusing there would make every minor bump a breaking change and defeat the point of having minors at all. - The same tolerance applies to the host reading the worker's result. Unknown key: ignore. Unknown method, or a known field with the wrong shape: refuse.
From the first frame after the result onward, the strict rule holds: neither side sends a field the other did not promise.
The rule that turns a mismatch into a refusal
Every version decision ends in a frame, and every wait has a deadline.
A refusal is an HTTP status, a WebSocket close code, or a JSON-RPC error — with both version values in it. Dropping the connection is not a refusal. Accepting the connection and going quiet is not a refusal.
And because a refusal can itself be lost, neither side may rely on receiving one. Both arm a timer from the socket opening —
handshakeSecondsfor the worker, twice that for the host, so the side that can dial again is the side that gives up first — and the host armsdialSecondsfrom the worker's creation for the cold start before that socket exists. The worker that hits its deadline closes, retries inside the reconnect window, and exits75; the host that hits either of its own marks the sessionfailedand tears it down. Every value is in Deadlines and in no other place on this page.
A mismatch is therefore bounded twice — once by the refusal, once by the deadline — and no path leaves a session sitting in starting forever. The two numbers in the refusal are what makes it a five-second diagnosis: the operator sees worker speaks agentfy.worker.v2, api speaks v1 and knows which of the two rolled.
What each side does afterwards
- Host. Session →
failed,failureReason: channel_version, both versions and both image tags recorded, teardown runs, no retry. A version mismatch is a deploy problem; retrying it burns node capacity and buries the cause under identical failures. - Worker. Exits
78(EX_CONFIG) with both versions on stderr. It does not retry and does not fall back — a worker that downgrades itself to keep a connection is worse than one that dies.
The compatibility window, which is what actually makes this survivable
The host accepts the current channel major and the previous one; a worker image speaks exactly one. With a single supported major on the host, every deploy has a window in which one of the two sides refuses everything.
The ordering follows from that: roll the api gateway first, so it speaks both N−1 and N; then roll the worker image to N; drop N−1 from the host only once no session can still be scheduled onto an N−1 image. A major that is dropped before the last old image is gone turns a routine deploy into an outage of the hands.
Error codes
JSON-RPC application codes (the -32000…-32099 range) and WebSocket close codes are part of this contract: a refusal that cannot be matched by a machine will not be matched by a person under pressure either.
| Code | Name | Sent by | When |
|---|---|---|---|
-32001 | CHANNEL_VERSION_MISMATCH | either | a frame or field belongs to a version the receiver did not negotiate |
-32002 | SESSION_MISMATCH | host | the worker's sessionId does not equal the token's sub |
-32003 | SAMPLING_DISABLED | host | any sampling/* request |
-32004 | CAPABILITY_NOT_NEGOTIATED | host | elicitation/*, roots/*, or anything else not declared |
-32005 | SESSION_NOT_RUNNING | worker | a tools/call before ready, or after release was accepted |
Divergences in the worker docs — named, not smoothed
Six places where Deploy, Use, Destroy and Implementation disagree with each other or leave a gap. They are recorded here rather than quietly patched into those pages, because each one is a decision someone made and the reasoning is worth keeping.
1 — Who sends initialize. Use → the handshake reads "worker boots → dials CONTROL_URL over WebSocket → MCP initialize (presents WORKER_TOKEN)", which puts the worker in the sender's seat. The same page, four paragraphs later, says "the worker is the TCP initiator yet still the MCP server; the gateway is the MCP client" — and in MCP initialize is a client→server request. Implemented literally on both sides, each waits for the other and the channel hangs: the exact failure this contract exists to prevent, and the reason it had to be written before either half. Settled: the host sends initialize, per MCP. The token moves to the WebSocket upgrade, where it is checked before any JSON-RPC exists — which is strictly better than carrying it in a frame.
2 — What a heartbeat is. Use says "Heartbeats (ws ping/pong)". Implementation lists heartbeat as a frame beside initialize, ready and release. Those are different layers with different guarantees: ping/pong is answered below the application and cannot carry a payload. Settled: an agentfy/heartbeat request with a response. Ping/pong stays as a dumb keepalive for intermediaries, and this page says plainly that it proves nothing about the process.
3 — What a heartbeat resets (the load-bearing one).Deploy — "heartbeats reset the idle timer" — and Destroy — the reaper is "rescheduled on every heartbeat" — make liveness reset the idle clock. Taken literally, a healthy idle session is never reaped, because a healthy idle worker heartbeats forever: precisely the leak the Destroy page exists to prevent. The same page also says "a follow-up turn within the window reuses the session and resets the timer", which is the other, correct clock. Both readings live on the same page. Settled: two clocks, one measurement. idleSeconds is reset by a finished tools/call only, measured in the worker, reported in every heartbeat, and read by both the reaper and the worker.
4 — Who declares ready. Deploy → boot & registration has the pod "dial back to Core over WebSocket and signal ready". The diagram in Use has the gateway arriving at "ready" after it has run tools/list. Settled: both are real events that shared a word. The worker's agentfy/ready declares the worker willing; the host's running state follows tools/list. The window between them is where a registration failure lives, and it now has a name.
5 — The bootstrap environment is listed three times and not identically.Implementation and Use both list SESSION_ID, CONTROL_URL, WORKER_TOKEN, TOOL_ALLOWLIST and a storage scope. The only place showing an actual manifest — Deploy — names the first, second and fourth, has no storage scope at all, and reaches the token through an unnamed secretRef. The manifest is labelled illustrative, so this is a gap rather than a contradiction, but WORKER_TOKEN is load-bearing for the worker's boot and it is absent from the one page an implementer will copy from.
Settled by 006 T104 (AGNT2-197): the pass is a file. WORKER_TOKEN_FILE names the path a projected Secret is mounted at, the manifest on Deploy now shows the volume, and setting both WORKER_TOKEN_FILE and WORKER_TOKEN is refused at boot — a half-migrated manifest would otherwise keep the value in the environment and say nothing about it.
6 — A constraint no page states: idleSeconds must be below maxExecSeconds. The Warm profile idles 10–30 minutes (Deploy → runtime modes) while the illustrative manifest caps the Job at activeDeadlineSeconds: 900, and Destroy asserts the max-idle ceiling is never disabled. Unless a profile's maxExecSeconds exceeds its own idle window, k8s kills a warm session part-way through the window it was promised, and the worker sees an unexplained connection drop — it cannot tell "the host is gone" from "I am about to be killed". This changes what silence means, so it belongs here: every profile must satisfy idleSeconds < maxExecSeconds, and the profile catalog is where that is asserted.
Decisions this page makes that were not already written down
Everything above that is not in the table below was already decided in Deploy / Use / Destroy / Implementation or in Locked decisions, and is restated here only because a contract has to be readable in one sitting. These are new:
| Decision | Why |
|---|---|
The token authenticates at the WebSocket upgrade, not inside initialize | Preserves MCP's own direction for initialize, and refuses an unauthenticated dial before any session state is allocated |
heartbeat is a request carrying idleSeconds | Only a request can go unanswered, which is what the worker's self-termination timer needs; only a payload can carry the one idle measurement both sides read |
elicitation and roots are withheld alongside sampling | The docs disable sampling. The same argument covers a worker asking a human a question and a host exposing its filesystem — all three are reverse capabilities the worker has no business holding |
| A bounded reconnect window before self-termination | A rolling api deploy should not kill every live worker; an unbounded retry is the leak the ceilings exist to prevent. The bound is what makes both true at once |
| Close codes and exit codes decide whether retry is permitted | "Retry on disconnect" retries a version mismatch forever; the cause is then buried under identical failures |
| The host accepts channel major N and N−1 | With one supported major, every separate roll-out of the two images has a window where the hands are simply unavailable |
-32001…-32005 as named codes | A refusal that cannot be matched by a machine will not be matched by a person at 3 a.m. either |
dialSeconds is a budget of its own, separate from handshakeSeconds | They are different waits — a cold start against a round trip. One number for both makes the host give up before the pod exists, always, and worst on the profile that bundles a browser |
One live connection, displaced by instanceId + attempt rather than refused outright | A flat refusal annuls the reconnect window in exactly the case it is written for. The two fields are what separates "the same process coming back" from "a second process shadowing a live one" |
| The effective minor is computed by both sides and re-checked in every heartbeat | The result frame belongs to the worker, so the host has no frame to announce it in; and a value only two private calculations agree on is a value nobody verified |
The initialize request is exempt from "send no field the other did not promise", and unknown _meta.agentfy keys are ignored | It is sent before any minor is known. Without the exemption plus the tolerance, every minor bump becomes a breaking change |
| The worker obeys the smaller of the manifest's ceiling and the frame's | A ceiling that can be raised over the wire is not a ceiling, and one that exists in a single place is one nobody can check |
Ten malformed frames close the connection (4006), and a parser never silently discards | Silently dropping garbage is how a version skew presents as a hang instead of a refusal |
| WebSocket ping/pong every 30 s, on top of the heartbeat | It stops intermediaries idling the socket out. It is explicitly not the liveness signal — that is the heartbeat, and the page says why |
No release while a tools/call is outstanding, unless the reason is deadline or failed | Otherwise a normal idle teardown races a running tool and loses its output, which the teardown order exists to prevent |
Exit 75 for silence, 78 for disagreement | They call for opposite responses — retry, versus deploy something different — and one code for both sends the operator hunting a version skew that is not there |
| The host's startup waits are consecutive, and its handshake budget is twice the worker's | Two timers over intervals that share an end are one timer, the shorter one — the longer budget is then unreachable. And at a seam both sides watch, the side that can dial again has to give up first, or the host tears down the session the retry was coming back to |
Open — and deliberately not decided here
- The default numbers. Every budget on this page has a stated default, and every default is overridable per
RuntimeProfile. The defaults were chosen to be consistent with the profile table in Deploy and are not measurements — nothing has run yet. They should be re-derived from the first real sessions. - Anything about infrastructure. The cluster, the node pools and the image registry are the owner's decisions and are untouched here; see Open questions. This page is a wire format, and it holds on a laptop-local worker driver exactly as it holds on Kubernetes.
- What the channel carries beyond the lifecycle.
tools/list,tools/call, progress andresource_linksemantics stay in Use. Splitting them across two pages is how a contract stops being readable in one sitting.
See also
- Deploy — the Job, the profiles, the security checklist.
- Use — the three channels, the work frames, the tool split.
- Destroy — session states, the teardown order, the hard caps.
- Implementation — the build prompt both halves are handed.
- Locked decisions — the tool channel entry, decided before this page existed.