Skip to content

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: DeployUsethis channelDestroy.

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.

HereNot here
The connection's direction, framing and authenticationThe user chat stream (channel 1) and the provision queue (channel 2) — Use → the protocol
initialize · ready · heartbeat · releasetools/list · tools/call · notifications/progress · resource_linkUse → on the wire
Identifier correspondence and the one-connection ruleWhich tool runs where — Use → the tool split
sampling and the other reverse capabilitiesProfiles, the Job manifest, the security checklist — Deploy
Timeouts, close codes, version refusalSession 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 / WebSocketMCP
workerclient — it initiatesserver — it owns the tools
api worker-gatewayserver — it acceptshost / 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 / -32600 and counted; ten malformed frames on one connection close it with 4006. 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:

RouteSet byOn the wire?Trusted?
sub of WORKER_TOKEN (Bearer, on the upgrade)the api, at mint timeyesyes — this is the authority
SESSION_ID, echoed in the initialize resultthe manifest builderyesno — compared against the token, never believed
the session label on the k8s Jobthe manifest buildernonot 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":

FieldWhereWhat it is
instanceId_meta.agentfy of the initialize resultminted once when the worker process boots; constant across every reconnect of that process
attempt_meta.agentfy of the initialize result0 on the first dial, +1 on each redial by that process
  • Same instanceId, higher attempt → accepted. The host closes the older socket with 4009 superseded 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-runs tools/list and replaces the registered toolset rather than adding a second one.
  • Same instanceId, attempt not higher → refused 4002. A stale retry arriving late must not displace a newer connection.
  • Different instanceId → refused 4002. 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.

FrameDirectionKindAnswerIf the answer never comes
initializehost → workerrequestinitialize resultworker retries, then exits 75; host fails the session
agentfy/readyworker → hostnotificationthe host's tools/listworker retries, then exits 75; host fails the session
agentfy/heartbeatworker → hostrequestheartbeat resultworker retries, then exits 75; host reaps the session
agentfy/releasehost → workerrequestrelease resulthost 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.

jsonc
// 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.
jsonc
// 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 dialSeconds the 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 not handshakeSeconds. 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 initialize within handshakeSeconds of the socket opening: close, retry inside the reconnect window, then exit 75. The host accepted the connection and then went quiet; that is a host-side fault and it may well pass.
  • Hostnotifications/initialized not received within 2 × handshakeSeconds of 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 is dialSeconds, above; the wait after it is readySeconds, next section.) On expiry: close 4003, 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.

jsonc
// 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 noticesThresholdWhat happens
Workerno tools/list after agentfy/ready for readyAckSecondstreats the host as absent → reconnect window → exit 75
Hostthe session not running within readySeconds of notifications/initializedagentfy/ready never came, or tools/list went unansweredclose 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.

jsonc
// 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 noticesThresholdWhat happens
Workerno answer to a heartbeat within heartbeatGraceSeconds (three missed)the control connection is considered lost → reconnect window → exit 75
Hostno heartbeat request for heartbeatGraceSeconds + heartbeatIntervalSecondssession → 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.

jsonc
// 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 noticesThresholdWhat happens
Hostno release result within releaseGraceSecondsdelete the worker anyway, record outputsFlushed: false on the session
Workerno release ever arrives, because the host is goneits 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 waitWhose timerBudget · defaultStarts atOn expiry
the pod dials homehostdialSeconds · 120 sthe worker is createdsession → failed (dial_timeout), teardown, no retry
initialize arrivesworkerhandshakeSeconds · 10 sthe socket opensclose → reconnect window → exit 75
the MCP handshake completeshost2 × handshakeSeconds · 20 sthe socket opensclose 4003, session → failed (handshake_timeout), no retry
the session reaches runninghostreadySeconds · 30 snotifications/initializedclose 4003, session → failed (ready_timeout), no retry
tools/list arrivesworkerreadyAckSeconds · 15 sagentfy/ready is sentclose → reconnect window → exit 75
a heartbeat is answeredworkerheartbeatGraceSeconds · 30 sthe heartbeat is sentreconnect window → exit 75
a heartbeat arriveshostheartbeatGraceSeconds + heartbeatIntervalSeconds · 40 sthe previous heartbeatclose 4004, session → failed, teardown
agentfy/release is answeredhostreleaseGraceSeconds · 30 srelease is sentdelete the worker anyway, record outputsFlushed: false
a lost channel comes backworkerreconnectWindowSeconds · 60 sthe channel is declared lostflush what is possible, exit 75
the worker stops workingeitheridleSeconds · 90 sthe last tools/call finishedthe reaper releases the session
the worker runs too longeithermaxExecSeconds · 900 sthe process startedhard 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:

  1. idleSeconds < maxExecSeconds — otherwise k8s kills a warm session part-way through the idle window it was promised. See divergence 6.
  2. dialSeconds > 0, with a value drawn from the profile's own image rather than the Light default.
  3. activeDeadlineSeconds ≥ dialSeconds + maxExecSeconds. Kubernetes counts activeDeadlineSeconds from the Job's start, so it includes the whole cold start; maxExecSeconds counts 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.

ExitNameMeansReached from
0released normallyan accepted agentfy/release, close 4005
75EX_TEMPFAILthe control plane went quiet and did not come backevery silence deadline above, after the reconnect window expires; close 4004
77EX_NOPERMthe token or the session was refusedclose 4002
78EX_CONFIGa retry changes nothing: the two sides cannot agree on a version or a frame format, or this worker could not bring itself up in timeHTTP 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.

  1. The socket drops, or the worker's own grace timer fires.
  2. The worker retries CONTROL_URL with exponential backoff (1 s, 2 s, 4 s… capped at 10 s) for at most reconnectWindowSeconds. Executors keep running; sticky state is untouched — the process never died, so cwd, env, background processes and the browser context survive.
  3. Each retry carries the process's instanceId and an incremented attempt. A successful reconnect is a fresh initialize bound to the same sessionId, and the host lets it displace the old channel under the one-connection rule — closing the older socket with 4009 rather 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.
  4. The window expires → the worker flushes what it can and exits 75 (EX_TEMPFAIL).

A close code decides whether retrying is even allowed.

CloseMeaningWorker retries?
4001channel or MCP version not supportedno — exit 78
4002session mismatch, token invalid or expired, duplicate connectionno — exit 77
4003handshake deadline missedno — exit 78
4004heartbeat lost (host side)no — exit 75
4005released, normalno — exit 0
4006too many malformed framesno — exit 78
4008host draining (api replica rolling)yes, within the window
4009superseded — this socket was replaced by a newer dial from the same processno, and nothing failed: the newer channel is already serving
any transport-level dropunknownyes, 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.

#WhereEnforced byStops
1the host's initialize requestcapabilities: {}sampling, elicitation and roots are absenta conforming worker from ever forming the request; MCP forbids a server using a capability the client did not declare
2the host's transport, before dispatchany inbound request whose method begins sampling/-32003 SAMPLING_DISABLED, audited on the session. elicitation/ and roots/-32004 CAPABILITY_NOT_NEGOTIATEDa 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
3the worker's transport, on the way outthe same guard on outbound framesa 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

LevelWhere it is statedWhat a mismatch does
Channel majorWebSocket subprotocol on the upgrade: agentfy.worker.v1The 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 versionprotocolVersion in initialize and its resultThe 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 waysPermitted 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:

  1. The host's initialize request carries the host's channelVersion. The worker's result carries the worker's.
  2. After the result, both sides hold both numbers, and both compute min(host, worker). Nothing needs to be told to anybody.
  3. Every agentfy/heartbeat carries the effective version as the worker computed it. If it differs from the host's own arithmetic the host refuses -32001 CHANNEL_VERSION_MISMATCH and closes 4001. 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 initialize request 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.agentfy keys rather than refusing. _meta is 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 openinghandshakeSeconds for the worker, twice that for the host, so the side that can dial again is the side that gives up first — and the host arms dialSeconds from 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 exits 75; the host that hits either of its own marks the session failed and 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.

CodeNameSent byWhen
-32001CHANNEL_VERSION_MISMATCHeithera frame or field belongs to a version the receiver did not negotiate
-32002SESSION_MISMATCHhostthe worker's sessionId does not equal the token's sub
-32003SAMPLING_DISABLEDhostany sampling/* request
-32004CAPABILITY_NOT_NEGOTIATEDhostelicitation/*, roots/*, or anything else not declared
-32005SESSION_NOT_RUNNINGworkera 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:

DecisionWhy
The token authenticates at the WebSocket upgrade, not inside initializePreserves MCP's own direction for initialize, and refuses an unauthenticated dial before any session state is allocated
heartbeat is a request carrying idleSecondsOnly 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 samplingThe 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-terminationA 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−1With one supported major, every separate roll-out of the two images has a window where the hands are simply unavailable
-32001…-32005 as named codesA 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 handshakeSecondsThey 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 outrightA 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 heartbeatThe 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 ignoredIt 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'sA 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 discardsSilently 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 heartbeatIt 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 failedOtherwise 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 disagreementThey 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'sTwo 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 and resource_link semantics 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.