Wire protocol

The wire protocol is the JSONL message format between a parent and a live child agent. It covers both directions: the child emits events and the parent sends control messages. packages/util/src/fen/util/wire.fnl (fen.util.wire) is the authoritative schema; this page summarizes it and does not restate field types. Golden examples of every message live in packages/util/tests/fixtures/wire/.

Envelope

Every line is one JSON object with the envelope fields v, seq, type, and run, plus the payload fields for that type. v is the protocol version; the current version is 1. seq is a positive integer, at most MAX-SAFE-INTEGER (2^53 - 1), that strictly increases per sender. run is the run id both sides agree on. Payload fields use Fen's kebab-case keys, and optional fields are omitted rather than sent as null. A line may not exceed MAX-LINE-BYTES, so a bounded drain can always consume it.

Child to parent events

The normalized display events are forwarded as-is: agent-started, user, llm-start, llm-end, assistant-text, assistant-text-delta, assistant-thinking, assistant-thinking-delta, assistant-stream-end, tool-call, tool-result, steering-injected, follow-up-injected, agent-turn-complete, error, and info. fen.util.wire.normalize produces them with transport bounds applied and fields coerced to their schema types; truncated payloads carry transport-truncated?. steering-injected and follow-up-injected may carry ref, the seq of the steer or follow-up control whose queued text was injected.

The live-child lifecycle adds these events:

Parent to child controls

Validation and rejection

fen.util.wire.decode, encode, and validate never throw on bad input. They return the message, or nil plus a rejection {code, reason, fatal?, seq, type, run, errors}. Rejection codes are malformed, too-large, invalid (bad envelope), version-mismatch, unknown-type (including a type from the other direction), invalid-payload, and out-of-order (from receive!). A line without v is an invalid envelope, while a different v is a version-mismatch. A version-mismatch is fatal: there are no compatibility shims, so the receiver stops the channel. A child answers any other rejected control line with control-ack built by fen.util.wire.rejection-ack. fen.util.wire.sender and next! stamp outgoing sequence numbers, and receiver and receive! enforce increasing incoming ones.

Run state machine

packages/util/src/fen/util/wire_session.fnl (fen.util.wire_session) is the authoritative state table; the child presenter drives it and a parent mirrors it. decide looks up a (state, control) pair, advance looks up an internal child event, and terminal? marks the exit statuses.

starting → ready → running ⇄ ready
running → closing → done
ready | running | closing → finalizing → done
any non-terminal → cancelled | failed | timed-out
Run state machine table
State \ controlpromptsteerfollow-upfinalizeclosecancel
startingrejectedrejectedrejectedrejectedrejectedaccepted → cancelled
readyaccepted → running, starts a turnaccepted → running, starts a turnaccepted → running, starts a turnaccepted → finalizing, starts the tool-free turnaccepted → doneaccepted → cancelled
runningrejectedaccepted, steering queueaccepted, follow-up queueaccepted → finalizing, interrupts the turnaccepted → closingaccepted → cancelled
closingrejectedrejectedrejectedaccepted → finalizing, interrupts the turnappliedaccepted → cancelled
finalizingrejectedrejectedrejectedappliedappliedaccepted → cancelled
terminalrejectedrejectedrejectedrejectedrejectedrejected

Internal events move the run without a control: a turn ending returns running to ready, finishes a closing run, and starts the tool-free turn in finalizing; the end of that turn finishes the run. A passed deadline moves any non-terminal state to timed-out, and a fatal error to failed. In a terminal state, the child waits for an interrupted turn to unwind (or throw) and then exits with that state.

Acks

Every control line gets exactly one control-ack, sent before any event the control causes.

A version-mismatch gets no ack: the child exits failed.

Turns and the result

A turn is one agent step; turn-started and turn-complete bracket it, and turn counts from 1 per run. turn-complete carries the last assistant stop reason (aborted for an interrupted turn) and the turn's summed usage. finalize interrupts a running turn at its next cooperative yield, pairs any unexecuted tool calls with cancelled results, then runs one turn with {:tool-choice :none} using the note (or a default instruction) as the user message. close in running lets the current turn finish. result is emitted exactly once, immediately before exit done, from the run's last assistant message; its usage sums the whole run. A final-text too long for one line is written whole to a new private temp file named by final-text-path, then cut at a UTF-8 boundary with truncated? true. The parent owns that file: it reads the full answer from it and removes it. If the file cannot be written, final-text-path is absent and only the cut text survives. Runs that end cancelled, failed, or timed-out emit no result.

Child presenter

fen --presenter rpc runs a live child over files: it tails the control file, appends events to the event file, and exits after its exit event.

Child presenter table
VariableMeaning
FEN_WIRE_CONTROL_PATHControl JSONL file the parent appends to; required, and may be created later.
FEN_WIRE_EVENT_PATHEvent JSONL file the child appends to; required.
FEN_WIRE_RUN_IDThe run value for both directions; defaults to run.
FEN_WIRE_DEADLINEOptional Unix time in seconds; past it the run exits timed-out.

The task arrives as the first prompt; other flags such as --provider, --model, tool policy, and session flags apply as for any presenter. The child forwards bus events whose type is a wire display event, normalized by fen.util.wire.normalize, and emits the lifecycle events itself. The exit code is 0 after exit done and 1 otherwise. --print and --prompt-file are rejected with --presenter rpc.

The child polls on the TUI's cadence: 30 ms per tick while a turn runs, 300 ms when idle, checking only the control file's size between reads. A control line too long for one read is rejected once and skipped to its newline. A control file that shrinks below the consumed offset is fatal (exit failed). A turn the runtime starts on its own (an idle follow-up) is reported with turn-started like a prompt. cancel, a deadline, and finalize take effect at the turn's next cooperative yield, so a tool that blocks without yielding delays them; a parent should keep a kill-after-grace backstop.

Parent

The subagent extension is the in-tree parent; Subagents describes how it maps runs onto controls. The parent creates the control file privately and appends the first prompt before it spawns the child. fen.extensions.subagent.channel is its {send! poll} channel over the two files: it stamps increasing control seq values, validates every event with receive!, and mirrors the run state from events alone by applying decide to each acknowledged control and advance to each turn-complete. The subagent answers with the full text from final-text-path when the result has one; a truncated? result without a readable file reaches the caller with an explicit truncation note and result-truncated? in the details.