Skip to content
Pilotoo
v0.1

Wire protocol

@pilotoo/chat-protocol is the contract between a gateway and every client. Its Zod schemas are the single source of truth, and every transport and environment speaks it. You rarely write protocol messages by hand, because chat-core does it for you; read this page when you build your own client or debug a run.

The rule behind every schema: commands are strict, responses are permissive. A command rejects keys it does not know, because that is a client defect or an attack. A response tolerates extra fields, because a client embedded in a lagging system must not refuse a whole answer over a field it does not recognise.

Direction Message Payload
server to client protocol:hello { protocol, minClientMajor }, sent once per connection
client to server run:start start command
client to server run:resume resume command
client to server run:cancel cancel command
client to server run:detach detach command
client to server thread:messages thread messages command; answered through the acknowledgement
server to client run:event a stream envelope
server to client task:status { threadId, task }
server to client protocol:error { code, message }
Command Fields Notes
run:start threadId, messageId, input (at most 16,000 characters), attachments?, clientTools?, forkFromMessageId? Needs text or at least one attachment. With forkFromMessageId, the run restarts from the checkpoint just before that message, which is replaced by this run’s message.
run:resume threadId, interruptId, answer, clientTools? answer is required. Answers one pending decision.
run:cancel runId
run:detach runId, toolCallId? Moves the run’s subagent task calls, or only toolCallId’s, to the background.
thread:messages threadId Joins the thread’s room and returns history.

Limits: at most 8 attachments per run (MAX_ATTACHMENTS_PER_RUN), each an image of image/png, image/jpeg, image/webp or image/gif up to 5 MiB (ATTACHMENT_MEDIA_TYPES, ATTACHMENT_MAX_BYTES), and at most 32 client tools per command.

clientTools appears on run:start and run:resume only. Each declaration has a name (letters, digits, _ and -, up to 64 characters), a description, an arguments JSON Schema, and a kind:

  • blocking: the run pauses and the client’s answer becomes the tool result. May declare a result schema.
  • one-way: drawn or acted on while the run carries on; returns nothing.

Nothing about client tools is stored; the next run declares its own set. A blocking call arrives as an INTERRUPT whose payload is { clientTool, arguments } and is answered with run:resume. See Client tools.

Every event travels inside an envelope. These are the event types, from agUiEventSchema:

Group Types
Run lifecycle RUN_STARTED, RUN_FINISHED, RUN_CANCELLED, RUN_ERROR
Text TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, TEXT_MESSAGE_END
Tool calls TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_END, TOOL_CALL_RESULT
Human decisions INTERRUPT, RUN_PAUSED
Reasoning REASONING_START, REASONING_CONTENT, REASONING_END
Plans TODOS_SNAPSHOT
Subagents SUBAGENT_STARTED, SUBAGENT_FINISHED

Points worth knowing:

  • RUN_CANCELLED is separate from RUN_ERROR: a deliberate stop is not a fault. Whatever had streamed stays in the transcript.
  • INTERRUPT carries an interruptId and a payload, and is always followed by RUN_PAUSED, which ends the segment. A graph that pauses on several decisions emits one INTERRUPT each, and each is answered by its own run:resume. RUN_PAUSED is then followed by RUN_FINISHED for the same run id, so older clients still see the run settle.
  • TODOS_SNAPSHOT is the whole list each time, so a reader who missed one is whole again at the next.
  • SUBAGENT_STARTED may carry taskId and childThreadId; SUBAGENT_FINISHED carries status (finished or error) and detached: true when the subagent goes on as a background task.
  • RUN_ERROR carries a free code and a message.
  • RUN_STARTED may carry the message that opened the run, so a window that joins mid-run shows the question.
{
v: 1, // PROTOCOL_VERSION.major
threadId: string, // on every envelope, so one is routable on its own
runId: string,
sequence: number, // positive integer, increasing within the run
event: { type: ..., ... },
}

Naming the thread on every envelope is what lets a second window, or a reconnect, render a run it did not start. The gateway broadcasts to a room keyed on the thread, which a socket joins by asking for that thread’s history. RUN_STARTED is a run’s sequence 1.

An event type a client has no case for is admitted and ignored (counted, not refused), so the gateway can learn to say one more thing without breaking old clients. A known type that fails validation is still an error.

thread:messages answers with ok: true and the thread’s history, or ok: false and an error:

Field (ok) Meaning
messages The checkpointed messages, each with id, role, content, createdAt and optional toolCalls, reasoning, attachments, task.
replay Present while a run is in flight: its envelopes as the gateway holds them, and truncated, which says the buffer dropped the beginning.
pendingInterrupt The first decision the thread waits on, if any.
todos The latest todo list, if the agent keeps one.
task Present when the thread is a background task’s own conversation.

The replay is the envelopes themselves, so a client folds them through the same path as the live stream, and the last of them is contiguous with the next envelope the room delivers. A reader who reloaded has no sequence to ask from, so the server offers what it holds rather than answering a bookmark.

protocol:error and the ok: false branch carry { code, message }:

Code Meaning
INVALID_COMMAND The command failed validation, or cannot be accepted now.
UNKNOWN_RUN The run is not active, or the conversation is not waiting on that decision.
DUPLICATE_MESSAGE A message with this id already exists in the thread.
CONVERSATION_NOT_FOUND The conversation does not exist.
FORK_POINT_NOT_FOUND The message to fork from is not in the thread.

Run failures are a different channel: they arrive as the RUN_ERROR event.

The same server also exposes REST routes (all authenticated unless stated): /health and /protocol (anonymous), /conversations, /agents, /agent-admin, /attachments, /presentations, /usage, /schedules and /model-gateway. GET /protocol returns the same object as protocol:hello.

PROTOCOL_VERSION is { major, minor } (currently { major: 1, minor: 17 }). major is the envelope’s v; minor counts additions.

Change Effect
Add an event type, an optional response field or an optional command field Increment minor. Every rule above already makes it tolerable in both directions. A client follows a gateway with a higher minor unchanged.
Change a field’s meaning, remove a required field, or change the sequencing contract Increment major, and minor returns to 0. The gateway must serve both versions while any client remains that cannot rebuild.

The gateway announces what it needs with protocol:hello { protocol, minClientMajor }, emitted to each socket as soon as the connection is identified, and with the same object at GET /protocol (anonymous). minClientMajor is the oldest major the gateway still serves; it rises only when the gateway stops serving a major, never with an addition.

chat-core sends nothing before the announcement. If minClientMajor exceeds its own major, or the gateway’s major differs from its own, the session stops with an error naming both versions and sends no further commands, not even after a reconnect. A gateway that announces nothing within protocolGraceMs (2 seconds) predates the announcement: it is followed as compatible, and the absence is logged with console.warn, as is an unreadable announcement.