# Runtime control protocol v1 Implementation authority: `klbr-runtime/src/protocol.rs` and the Python runtime, checked against `tests/runtime-fixtures/envelopes.json`. This protocol is local to the starter, not a claimed compatible implementation of Prime or the old klbr IPC protocol. ## Transport and bounds One dedicated Unix-domain stream socket per worker, located in a private temporary directory created by the host. A random startup token binds the expected subprocess handshake. This is distinct from stdout/stderr, and is not a network-accessible service. Processes run with same-user trust; the token and framing are not an OS sandbox. Each frame is four unsigned big-endian bytes giving the UTF-8 JSON payload length, followed by that many bytes. Length must be 1..1,048,576. All outer fields are required: ```json {"version":1,"session_id":"root","generation":"g-1","message":{"kind":"shutdown"}} ``` Identifiers are 1..128 ASCII letters/digits/underscore/dot/hyphen. Unknown envelope and typed message fields fail. Version, generation and session mismatches fail. NaN/Infinity are not valid wire numbers. Python's frame decoder rejects duplicate JSON object keys; Rust derives reject duplicate typed fields. Arbitrary nested `serde_json::Value` payload maps are not yet a duplicate-key equivalence guarantee between parsers; strengthen the shared decoder before accepting third-party wire producers. This is a trusted local protocol, not a claim of a fully fuzzed hostile-input parser. On EOF, bad framing, timed-out/abandoned Rust exchange, or identity mismatch, invalidate the connection/generation. Never attempt to resume a new frame after a partial payload. Output limits: cell source <=262,144 bytes; returned text <=65,536 bytes across <=256 records; error text <=65,536 bytes. The Python runtime currently returns output on completion, not as a continuous stream. Excess is counted and discarded. Raw/native stdout/stderr are continuously drained by the Rust worker into separate bounded 8 KiB diagnostic tails. ## Message vocabulary `hello`: token, role (`workbench` or `hooks`), revision (null for workbench), and slots (empty for workbench; exactly `attention.plan`, `delivery.review` for this hook worker). `execute`: operation `id` and `code`, allowed in workbench only. One foreground operation per worker. Namespace and future flags persist. The final expression has a displayed repr; `_` retains its last non-None value. Evaluation is arbitrary CPython, not sandboxed Python. `invoke`: operation `id`, exact `slot`, typed domain `input`, hook role only. A handler is an importable source entrypoint, not a pickled closure. The workbench is never made to execute an authoritative send-review hook behind the cell waiting for that review. `host_request`: request `id`, `execution_id`, and `request`. Implemented request variants: Local speech and the turn boundary: ```json {"method":"local.send","text":"hello"} {"method":"runtime.wait","seconds":null,"reason":"waiting for input"} {"method":"hooks.list"} {"method":"hooks.describe","slot":"delivery.review"} {"method":"runtime.inspect"} {"method":"runtime.instructions"} {"method":"skills.index"} {"method":"skills.load","id":"workspace"} ``` Source work and policy: ```json {"method":"workspace.info"} {"method":"workspace.run","argv":["cargo","check"],"cwd":".","env":{},"timeout_ms":120000,"max_output_bytes":750000} {"method":"evidence.resolve","refs":["event:42"]} {"method":"behavior.activate","path":"/abs/releases/","expected_epoch":3} {"method":"maintenance.originate","purpose":"...","reason":"...","evidence_refs":[]} {"method":"maintenance.assess","work_item_id":"...","outcome":"no-change"} {"method":"orientation.stage","candidate_id":"c","text":"...","target":"...","scope":{"kind":"global"}} {"method":"orientation.current","scope":{"kind":"global"}} {"method":"orientation.adopt","candidate_id":"c","operation_id":"op-1"} {"method":"frontier.select"} ``` Peers and remote namespaces: ```json {"method":"peer.open","mode":"independent","evidence":[]} {"method":"peer.send","peer_id":"p","request_id":"req-1","content":"...","evidence":[]} {"method":"peer.result","request_id":"req-1","wait_ms":0} {"method":"peer.submit_result","request_id":"req-1","result":{}} {"method":"peer.cancel","request_id":"req-1"} {"method":"peer.get","peer_id":"p"} {"method":"peer.list"} {"method":"peer.stop","peer_id":"p","drain":true} {"method":"remote.ensure","target":"build-host"} {"method":"remote.call","target":"build-host","namespace_id":"n","namespace_generation":"g","operation_id":"op","operation":"execute","payload":{}} {"method":"remote.cancel","namespace_id":"n","namespace_generation":"g","operation_id":"op"} {"method":"remote.close","namespace_id":"n","namespace_generation":"g"} ``` `peer.open` also accepts `agents.open`, and `peer.send` also accepts `peer.message`. `host_reply` repeats request/execution identity and contains either `{"kind":"ok","value":...}` or `{"kind":"error","code":"...","message":"..."}`. The worker host enforces a maximum of 128 requests per foreground cell. Pending replies are correlated by identity, not arrival order. The Python reader keeps running while a cell awaits an RPC. Late replies after cancellation are discarded rather than satisfying another cell's request. `completed` repeats the operation `id` with one of: ```json {"kind":"cell","status":"ok","output":[{"stream":"result","text":"42"}],"truncated_bytes":0,"error":null} {"kind":"hook","value":{"action":"allow"}} {"kind":"failed","error":"worker-level failure description"} ``` Cell statuses: `ok`, `error`, `yielded`, `interrupted`. Output streams: `stdout`, `stderr`, `result`. Worker death has no invented completion; the host creates a failed/interrupted record from its own observation. A worker cannot claim a committed yield without the host having accepted the wait. `interrupt` targets one operation ID. Python supports cooperative task cancellation; the current Rust management API conservatively terminates the generation instead. `shutdown` requests clean worker exit. Rust retains the final hard-stop authority when code blocks or catches cancellation. Only the direct child is currently managed, not all descendants. ## The instruction header and its revisions The head of a request is the text this transcript pinned, not the live contents of the runtime's instruction sources. `prompt.header` records the pin; `prompt.revision` records a change: ```json {"type":"header_pin","digest":"","text":"...","reason":"session_start|fold|fold_covered_revision","pinned_at_ms":0} {"type":"header_revision","digest":"","change":{"form":"diff"|"full", ...},"sources":[{"name":"...","digest":"...","text":"..."}],"actor":{"origin":"session|host","execution_id":"...","step":1,"path":"...","reason":"..."},"observed_at_ms":0} ``` Three rules, in the order they apply: 1. No pin yet: the transcript pins the current text (`session_start`) and runs under it. 2. The text changed and this transcript has (or can take) the change as an observation: the head keeps its bytes and the observation renders at the sequence where it was noticed. A change a session makes through a tool call is attributed to that execution; a change with nothing in the transcript to attribute it to is reported as `host`, with what is known and no invented actor. The observation is written once and replayed like any other record. 3. A fold moves the head. Installing a frontier rebuilds the prefix anyway, so the current text costs nothing extra there, and observations the frontier covers can no longer be rendered - a head whose only support was one of those must move (`fold_covered_revision`) rather than silently drop an instruction the model is running under. A revision is carried as **verified patches** against the newest text the transcript can reconstruct, and as the whole text when a patch does not reproduce that text exactly: ```json {"digest":"","change":{"form":"diff","base_digest":"","patches":[ {"source":"contract","start_line":99,"old_lines":["\n"],"new_lines":["A revision ...\n","\n"]}]}, "sources":[{"name":"contract","digest":"...","text":"..."}],"actor":{"origin":"host","reason":"..."}} ``` Verification is not optional: a patch that does not reconstruct its text is replaced by the whole text, so a reader never reconstructs an instruction text nobody wrote. `runtime.instructions` resolves the chain on the host for a reader that wants the exact current text instead of applying patches by hand; a link that does not reconstruct stops the chain at the last text that did. Patches are per source while the two source sets line up by name. When they do not - a head pinned before sources were recorded, a source that appeared or vanished - the change is patched over the joined texts under the source name `instructions` instead. A rename then reads as the edit it is rather than as a rewrite of everything under both names, and a head whose parts are unknown is one honestly anonymous source rather than a claim about parts nobody recorded. The rendered observation names the mechanism, because two instruction texts in one request are otherwise ambiguous: ```text [2026-09-13T19:54:53Z · instructions changed for this runtime · changed outside this transcript] the header of this transcript is the revision it started under; the text below is current from here on. ``` Why it is shaped this way: rewriting a head invalidates every block after it, and it rewrites what the transcript claims to have been running under all along. Appending costs the observation's own tokens. A pin move costs the prefix, which is why it happens at folds and nowhere else. ## What the request costs: cache reporting Usage fields are read from every shape the routes we talk to actually send, and never derived: `prompt_tokens_details.cached_tokens` and `cache_write_tokens` (also under `input_tokens_details`), or DeepSeek's `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens`. `cached_prompt_tokens`, `cache_write_tokens` and `uncached_prompt_tokens` stay absent when the route said nothing about them: an absent measurement and a reported zero are different records, and `prompt_tokens - cached_prompt_tokens` is our arithmetic, not the route's. Which cache fields a request may carry is the route's declared capability (see DOGFOOD.md); `prompt_cache_retention` and `prompt_cache_options` are stripped from route options and only re-added by a profile that says the route accepts them. ## Framing what the model reads An input is assembled into the request with a frame in front of the sender's own words, derived from the durable event on every assembly: ```text [2026-09-13T18:59:00Z · operator] do the thing ``` The label is the event's source, or `peer request from ` when the payload carries a peer request identity - the recipient has to be able to answer it, and the model never sees the sibling fields. Inputs whose event has no usable time say `time unknown` rather than rendering epoch zero as 1970. The stored content is exactly what arrived, so a frame is never part of a durable record and never has to be parsed back out of one. Tool results carry the same kind of line: the instant the observation was made, plus how long the cell ran when one ran at all. ```text [2026-09-13T21:17:22Z · ran 420ms] ``` "It took 420ms" is a fact about the past and stays true on replay. A relative rendering such as "four minutes ago" is deliberately absent from stored content: the same block is replayed later and would then assert something false. Currency is stated by the host clock instead - `runtime.inspect` returns `now` and `now_ms` - so a reader compares two instants rather than trusting a stale phrase. `now` uses the same `Z` shape as these stamps. ## Scope, waiting and receipts Host effects require the current live execution plus matching generation. Background tasks inherit an origin for diagnostics, but that scope expires when the foreground cell ends. After terminal wait, catching the interpreter sentinel does not restore scope authority. The host also rejects further requests from a yielded execution. Wait seconds are null or finite >0 and <=600. The host records the deadline/state and checks for already-pending input in one transaction. No suspended Python continuation is persisted. Wakeups start a future model/cell boundary; code below `await runtime.wait(...)` does not run normally. The caller must not mistake ordinary `asyncio.sleep` for this durable wait. Local send receipt is `effect_id`, `status` (`confirmed` or `held`), `event_id` (nullable), and `reason` (nullable). Confirmed means a durable operator-lane event exists. Held is not approval, queue-success or proof anyone received the message. There is no Discord transport in this slice. A retry with the same execution/request identity returns the existing receipt; a deliberately new request can send identical text again. ## Skills A skill is a directory holding `SKILL.md` whose first non-empty line is `# `, where `` equals the directory name. There is no other field: a header beside the body would be a second, staler definition of it. The body is the trail to the state the id names - the moves, the receipts, the refusals - not a signature list, because the signatures are already in the installed code. Two roots form one namespace and their order is not precedence. Builtin skills come from `KLBR_SKILLS_DIR`, defaulting to `/skills`, and are allowed to be absent. Managed skills come from `/skills`, which `init`/`serve` create so an agent can write one without inventing the directory. An id defined in both roots is refused rather than resolved, as is a title that disagrees with its directory, a non-UTF-8 or oversize file, or a symlinked directory. Refused ids are reported with their reason by `skills.index`, and by the assembled contract, so an agent that writes a skill learns why it was not offered. The model-visible index is ids only. The contract message carries the ids, and the host `skills.load` request carries one body; a body that differs from the digest the host just indexed is refused rather than half-read. Reading grants nothing: a skill cannot widen the delivery review and cannot outrank the contract. A local spawn also materializes the whole validated set beside the control socket and passes it as `--skills `. The worker reads it before it connects, so the payload file cannot outlive the handshake, makes the ids importable as `klbr.skills.` (a synthetic module whose documentation is the body), and echoes the set's revision in `hello`. That echo is what `kernel.reset` records, so the durable log names the instruction set a generation actually loaded. A body written during a turn is therefore offered on the next generation, not mid-turn. ## Implemented decision slots `attention.plan` input: positive event_id, host-classified source (`operator`, `discord.dm`, `discord.mention`, `discord.ambient`), nonnegative receipt timestamp. Result: wake with priority, defer with bounded seconds, or ignore with reason. Operator attention cannot be suppressed or forged from an external source by returning a priority. This is a policy API awaiting authenticated ingress/model-coordinator integration. `delivery.review` input: effect_id, destination=`operator`, bounded message text. Result: `allow` or `hold` with a reason. Any invalid/unavailable required review holds the send. No host-effect scope is given to a hook. Additional domain hooks should be added alongside the owning implementation, not as no-op placeholders. The host's two-second invocation budget includes queueing. One bounded actor serves a hook worker; a hung callback can delay other calls until timeout invalidates that generation. There is no separate per-handler scheduling or automatic hook restart yet. ## Source revisions and activation A manifest contains version 1 and exactly the two implemented entrypoints: ```json {"version":1,"hooks":{"attention.plan":"klbr_hooks.defaults:attention","delivery.review":"klbr_hooks.defaults:delivery"}} ``` Handlers receive `(request, context)` and return the corresponding SDK decision dataclass, synchronously or asynchronously. Startup validates the import/call signature, not semantic behavior. Imports themselves are trusted executable code and can fail or block. Source identity hashes sorted POSIX relative filenames and exact bytes. For each file feed SHA-256: u64BE name-byte-length, UTF-8 name, u64BE content-length, content. Source snapshots allow `.py`, `.json`, `.md`, maximum 128 files/2 MiB, no symlinks. Run source tests with `-B` or put caches outside the draft. The shared `behavior.json` fixture fixes the initial digest. Database epoch is activation authority; a path/symlink is not a second active pointer. Candidate handshake precedes the compare-and-swap switch. Each executing cell pins its policy object, so later hooks in that cell use the same revision even during activation. A hook-only change does not replace the workbench. A broader environment change is not yet implemented.