ker nodes connect to the control plane at wss://<public-host>/nodes/socket through the TLS proxy,
or ws://<loopback-host>:<port>/nodes/socket in local mode. The node opens the
connection; the server never dials a node. The current node protocol version is 4, the current
session-store version is 9, and every frame is UTF-8 JSON sent as a WebSocket text frame.
Remote mode is enabled by --public-url https://<public-host>. ker speaks HTTP behind a
TLS-terminating proxy; see the deployment guide. The socket upgrade must preserve
the configured public Host. An absent Origin is accepted for native nodes; if supplied, it
must match the public origin. The upgrade does not require a browser device credential: the
first socket frame authenticates the node.
Publicly trusted certificates need no node configuration. For a private CA, start the node with
NODE_EXTRA_CA_CERTS=/absolute/path/to/ca.pem; keep TLS certificate verification enabled.
POST /nodes/enrollments creates a random, single-use token that expires after 15 minutes. The
response includes the exact ker node --server <url> --token <token> command, using the public
HTTPS origin in remote mode. Creating an enrollment requires a paired device there. On its first
connection, the node sends enroll with that token and its stable identity. The server stores only
a SHA-256 hash of the token, consumes it transactionally, creates a random per-node secret, stores
only that secret's hash, and returns the secret once in welcome.
The node writes the server URL and secret to ~/.ker/node.json with mode 0600. Later connections
send auth with the node ID and secret. Enrolling the same node identity again rotates its secret.
Revocation disconnects the active socket and prevents later authentication without deleting the
node's catalog rows or session logs. The bundled daemon marks its in-process node as local and
refuses to revoke it.
The first frame must be enroll or auth and must arrive within five seconds. Invalid, expired, or
spent tokens, invalid credentials, revoked nodes, and incompatible versions receive refused and
the socket closes.
node plane
-- connect -------------------------->
-- enroll | auth -------------------->
<-- welcome { nodeId, secret? } ------
-- hello { versions, runtimes, running } ------>
-- records ... ----------------------> pending spool, one session at a time
<-- ack | reject ---------------------
-- drained -------------------------->
<-- ready { recover } ----------------
-- load / records / delta ... ------->
<-- call / chunk / end / hb ... ------
hello carries nodeProtocol, storeVersion, runtimes, and the IDs of sessions still running
in the node process. Each runtime describes the choices available on that node. The native entry
is { kind: "native", default: { kind: "native", provider, model, reasoningEffort? }, models },
where each model has provider, id, optional contextWindow and maxOutputTokens, and
reasoningEfforts. The default comes from the node config; the models come from its registry.
Headless entries have kind: "claude-code" | "codex", the installed CLI version, a default
runtime, registry-filtered models, and modes: [{ id, name, description }]. Their runtime is
{ kind, mode, model?, reasoningEffort? }; omitted model and effort select vendor defaults. The
node probes installed binaries asynchronously before advertising its runtimes. An unavailable CLI
is omitted from the handshake. GET /nodes relays the connected handle's runtimes and returns [] for offline nodes.
The node drains every existing spool file before sending drained. The server then
registers the connection and returns busy sessions assigned to that node but absent from
hello.running; the node loads and recovers those sessions, then drains the spool again before
reporting itself connected. The second drain includes records appended after drained but before
ready arrived.
A second connection with the same node ID replaces the older socket.
Run only one ker node process against a given node identity and spool directory. Two processes
using the defaults share ~/.ker/node.json and ~/.ker/spool, so each new connection replaces the
other and both can race on spool files.
| Frame | Fields | Meaning |
|---|---|---|
enroll |
token, identity { id, name, createdAt } |
Exchange a one-time token for a node secret. |
auth |
nodeId, secret |
Authenticate an enrolled node. |
hello |
nodeProtocol, storeVersion, runtimes, running |
Declare compatibility, available runtimes, and in-memory running sessions. |
records |
sessionId, records |
Send one or more chained store records from the spool. |
drained |
— | Declare that the startup spool has been sent. |
delta |
sessionId, event |
Publish an ephemeral message or reasoning delta. |
load |
id, sessionId |
Request the canonical history before executing a session. |
credential |
id, provider |
Request a native provider credential after the handshake. |
result |
id, value |
Complete a plane-initiated call. |
error |
id, code, message |
Fail a call with invalid_cwd, unknown_session, turn_unavailable, context_exhausted, invalid_runtime, or internal. |
| Frame | Fields | Meaning |
|---|---|---|
welcome |
nodeId, secret? |
Accept authentication; secret appears only after enrollment. |
refused |
code, message |
Refuse with unauthorized, revoked, token_expired, token_used, or version_mismatch. |
ready |
recover |
Finish the handshake and name sessions requiring recovery. |
ack |
sessionId, recordId |
Confirm every record through recordId. |
reject |
sessionId, recordId, reason: "chain" |
Reject a batch that forks the canonical chain. |
call |
id, method, args |
Invoke createSession, admit, compact, cancel, recover, resolveProjectRoot, observeGitRemote, or folderExists. |
chunk |
id, text |
Return a portion of a JSONL history load. |
end |
id, found |
Finish a history load. |
hb |
— | Keep the connection live. |
credential |
id, auth, expiresAt?, error? |
Answer a credential request; never carries a refresh token. |
Frames are runtime-validated against @ker-ai/protocol/node. Unknown fields or frame types close
the connection. The server limits incoming frames to 16 MiB and splits history responses into
1 MiB text chunks. A node may load, append to, or publish deltas for only sessions assigned to its
identity. A new-session batch must begin with a session record whose session.id equals the
frame's sessionId and whose session.nodeId equals the authenticated node ID.
createSession takes [cwd] to use the node default or [cwd, runtime] for an explicit choice.
The runtime is validated on the node and saved in SessionDescriptor.runtime in the first store
record. Native runtimes are { kind: "native", provider, model, reasoningEffort? }; malformed
choices, unavailable runtime kinds, and unknown headless modes return an error frame with code
invalid_runtime. HTTP session creation maps that failure to 400 invalid_session. Reopening a session uses its saved runtime,
including its provider and effort, regardless of later default changes. Credentials resolve for
that provider at request time.
Upgrade the plane and nodes together. A protocol-3 or older hello is refused with version_mismatch, even
though it has no runtimes field. Store-v8 and older logs remain unreadable under store v9.
Headless sessions persist { type: "child", id } when the vendor session is first created, and
resume that ID on subsequent process starts. Reopening does not depend on the startup advertisement;
a binary removed after creation produces runtime_unavailable on the next start. Missing vendor
history permits one fresh-start retry only when ker has no assistant message in its display
history. Claude reuses the ker session ID; Codex creates a new thread and ker records its ID.
Otherwise missing history produces child_session_missing. Ker never injects its display
transcript into a new vendor conversation. Vendor history remains node-local and is not replicated to the plane.
Headless sessions have no native definition or instruction records, pruning, automatic compaction,
or native context admission ceiling. Their stats use vendor-reported context usage and limits.
Before each native model request, the node sends { type: "credential", id, provider }. Only an
authenticated, drained node may request credentials; an early request closes the socket. The
plane returns { type: "credential", id, auth, expiresAt?, error? }. auth is either
{ kind: "apikey", key }, { kind: "oauth", accessToken, accountId }, or null. OAuth answers
include their ISO expiry; refresh tokens remain on the plane.
auth: null without an error is authoritative absence: the node clears its remembered copy and
emits credential_missing with the provider. auth: null with an error means temporary failure.
A disconnect or temporary failure permits the last in-memory key, or an OAuth token with more
than 60 seconds remaining, to be reused. An aborted caller never uses this fallback. Copies are
never written to disk. Cancel stops the wait while an already-started refresh finishes and saves
its rotated token. The plane serializes refresh, key replacement, login completion, and logout
per provider; a logout queued behind a refresh removes its result.
The plane stores credentials in catalog v7 and serves only summaries through the five private
/providers HTTP routes. A refresh 401 or 400 invalid_grant deletes the login and answers
explicit absence; network, timeout, 429, and server errors keep the row. Token calls time out after
45 seconds. Headless runtimes continue using their vendor CLI's node-local credentials.
The plane's store is canonical. Store records carry recordId and previousRecordId; the server
writes an incoming batch only when it continues the stored tail. Already-present record IDs are
idempotent, which makes reconnect replay safe. A fork receives reject, marks the catalog session
unreadable, drops that session's spool, and aborts its running work.
If a startup spool names a session for which the plane has neither a catalog row nor a store file,
and its first record cannot create that session, the plane sends reject without creating an
unreadable session. The node drops the stale spool and reconnects. A batch targeting a known session
owned by another node is an authorization failure and closes the socket instead.
The node writes each batch to ~/.ker/spool/<sessionId>.jsonl before sending it. An ack removes
the acknowledged prefix and deletes the file when no records remain. If the socket disappears,
an admitted turn can continue writing to the spool. Reconnection uses exponential backoff from one
second to one minute, re-authenticates, drains the spool, and then resumes normal calls. Live deltas
are not spooled; the durable assistant record contains the final text. Usage, successful
compaction, and prune batches include a stats record so the plane's context estimate reflects the
latest node-side context immediately.
When a history load reconciles a non-empty spool, the node removes only the contiguous pending prefix whose record IDs are already canonical. If the first pending record is absent from the loaded history, the whole spool remains pending.
The server sends a WebSocket ping and hb every 15 seconds and terminates a node after two missed
pongs. The node closes and reconnects if it receives no frame for 45 seconds. Protocol or store
version mismatches are hard failures; there is no compatibility negotiation.
Node-initiated closes use private application codes because Node's built-in WebSocket client accepts
only 1000 or 3000–4999: 4003 for a required text frame, 4008 for an invalid or refused
frame, 4011 for an internal node failure, and 4013 for a heartbeat timeout. Server-initiated
closes use the corresponding standard codes through ws.