Skip to content

Latest commit

 

History

History
882 lines (745 loc) · 51.5 KB

agent-connection.md

File metadata and controls

882 lines (745 loc) · 51.5 KB

Agent connection

The agent connection is the WebSocket that lets an agent running outside a Chopin server act as a document's Planner. Chopin uses it when it runs HARNESS=remote: the server keeps the document and its tools, and the client runs the model.

This page is enough to write a client. It covers the endpoint, the handshake, every message, the limits, and how a connection ends. Hosted agent says what the server does with a connection, and Self-hosting how to deploy it.

@chopin/agent-link (packages/agent-link) holds the server's schemas. Its client, @chopin/agent-link/client, uses nothing at runtime but this format, JSON, and the platform WebSocket. The remote harness contract suite runs it in Bun and as a separate Node.js process against a real socket.

Endpoint and authentication

Open a WebSocket to wss://<chopin origin>/api/v1/agent (ws: only for a loopback development server). It shares the application's origin and port with /ws and /mcp, behind the same TLS-terminating proxy. The route exists only when the server runs with AGENT on and HARNESS=remote. Otherwise every request to it, upgrade or not, gets HTTP 404 before anything else is checked.

The upgrade request needs two headers:

  • Sec-WebSocket-Protocol must offer chopin.agent.v1, the subprotocol this page describes. It may offer others too. The server always answers with chopin.agent.v1.
  • Authorization: Bearer <GitHub token>, the same bearer local agent MCP uses.

It may also send Chopin-Agent-Features, naming optional features it wants.

GET /api/v1/agent HTTP/1.1
Host: chopin.example
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Protocol: chopin.agent.v1
Authorization: Bearer <token>

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chopin.agent.v1

Bun's and Node.js's built-in WebSocket take both as options: new WebSocket(url, { protocols: ["chopin.agent.v1"], headers }) (checked on Node.js 24). Browsers cannot send the bearer. A client should not send an Origin header. If it does, it must be the server's own origin.

The bearer may instead be a paired machine's Planner credential (see Planner backend commands): chopin_p_ followed by 43 base64url characters. It attaches only the document it was issued for, and the account it attaches as is the machine's owner.

The server checks the request before upgrading and refuses over HTTP, so nothing opens. It checks in this order:

Status Meaning
404 The server does not run HARNESS=remote with AGENT on
426 The request does not offer chopin.agent.v1
403 A foreign Origin
401 Missing or malformed bearer, GitHub rejected the token, or an unknown or expired Planner credential, or its machine is revoked
403 The account fails instance admission
503 GitHub is unavailable or rate limiting; retry later
500 Any other admission failure

A 426 has a JSON body naming the subprotocols the server speaks:

{ "code": "subprotocol-required", "supported": ["chopin.agent.v1"] }

Versions

The subprotocol is the version. A breaking change to the messages gets a new subprotocol, chopin.agent.v2, instead of changing chopin.agent.v1, and a server lists every subprotocol it speaks in supported. A client offers the ones it speaks.

The version in attach is the same number: 1 for chopin.agent.v1.

Features

An additive change a client must know the server accepts is an optional feature instead of a new version. The client names the features it wants, comma-separated, in the upgrade request's Chopin-Agent-Features header:

Chopin-Agent-Features: run-stage-deltas

The server ignores names it does not know. When the request has the header, attached carries features: the requested features this server supports, possibly none. Without the header attached has no features, so a client that validates attached strictly and never asks never sees the field. A server from before features ignores the header and answers without features; a client must then use none of them.

Feature What it allows
run-stage-deltas Delta run cards, with no stage count limit

Pings

The server sends the connection a WebSocket ping control frame (RFC 6455) every 15 seconds from the moment it opens. The client must answer each with a pong. Standard clients do this on their own, including Bun's and Node.js's built-in WebSocket and the ws package; pings are control frames, not messages, and carry no JSON. Any frame from the client, a pong or a message, shows the connection is alive.

A client that is slow to read leaves the server's frames to it queued. While anything is queued the server sends no ping, since a ping can be dropped from a full queue. A queue getting shorter puts off the close by another timeout, but it does not count as hearing from the client: the kernel's buffers or a proxy in front of the client can drain it while the client reads nothing. A client that has stopped reading entirely keeps a queue that never shrinks, and counts as silent.

A connection the server hears nothing from for 120 seconds is treated as dropped, such as one whose network silently went away. However much of its queue drains meanwhile, a connection that sends nothing at all, not even a pong, for 15 minutes is dropped too. The server closes it with 1001 and reason unresponsive, without an error or refused message, and releases it exactly as any dropped connection (see Closing): its account keeps the document's Planner for the grace period in Ownership. The operator sets the interval, the timeout and the longest silence with AGENT_CONNECTION_PING_MS, AGENT_CONNECTION_TIMEOUT_MS and AGENT_CONNECTION_MAX_SILENCE_MS.

So a client working through a large backlog must still send something within the longest silence. A client that answers pings needs nothing more: once it has read the queue to the end, the server pings it again and its pong counts. The server may queue up to about 16 MiB for a client, so the longest silence is a trade-off:

  • A client that reads at about 19 KiB/s or faster can always drain a full 16 MiB queue and answer the next ping within the default 15 minutes.
  • A client that has stopped reading behind a proxy that keeps accepting bytes is released after 15 minutes at the latest, and usually much sooner: once the proxy's and the kernel's buffers fill, the queue stops shrinking and the 120-second timeout applies.
  • An operator can lower AGENT_CONNECTION_MAX_SILENCE_MS to release such a client sooner, at the cost of closing slower readers, or raise it, up to an hour, for slower connections.

The server cannot see a frame until it has arrived whole, and a client's pongs wait behind a frame it is still sending. So every frame must arrive within the timeout of the last frame heard before it. With the defaults, a 2 MiB frame needs a sustained client upload of about 17 KiB/s (2 MiB in 120 seconds), or about 20 KiB/s if it starts just before a ping is due (2 MiB in the 105 seconds left). A client on a slower uplink is closed as unresponsive while it uploads its largest frames; raise AGENT_CONNECTION_TIMEOUT_MS for it.

Frames

Every message is one JSON object in one text frame, with a string type. A frame may hold at most 2 MiB (2,097,152 bytes) of UTF-8. Objects are strict: a field this page does not list is refused. Binary frames are refused.

Shared limits, in characters unless stated:

Kind Fields Limit
id session, turn, request, toolCallId, part id, run and stage id, run, workflowRunId, workflowStageId, rootRunId, active and paused entries, document.id in attached 1 to 128
name tool names, toolName, names entries, modelId, responseFormat.name 1 to 128
short question header, finishReason.raw up to 128
note message, every title, error, turn.model, responseFormat.description, question text, option label and description, choices entries, placeholder, initial, run and stage name up to 4000
text prompt, instructions, delta, input the frame

Other fields state their own limits where they appear.

? marks an optional field. json is any JSON value.

Handshake

The client's first message must be attach, and it must send nothing else until the server answers with attached or refused.

{ "type": "attach", "version": 1, "document": "<document id or URL>", "checkout?": { "origin?": "", "branch?": "", "head?": "" } }
  • version is the protocol version the client speaks; this page is version 1.
  • document is a document id or its Chopin URL, at most 2048 characters.
  • checkout describes the client's working tree: origin (up to 2048), branch (up to 256), head (up to 128). It is informational; the server does not verify it.

The server answers:

{ "type": "attached", "version": 1, "document": { "id": "", "title": "", "repository": "owner/name", "url?": "" }, "features?": [] }
{ "type": "refused", "code": "<refusal code>", "message": "" }

attached.document.repository is owner/name, at most 257 characters, and url at most 2048. features, present only when the client asked for features, lists at most 16 names, each an ASCII letter or digit then up to 127 letters, digits, ., _ or -. After refused the server closes the connection.

Ownership

Attaching makes the client the document's Planner and its account the document's Planner owner. attach succeeds when all of these hold:

  • The bearer's GitHub account has push or administration access to the document's repository. Pull access alone is refused with repository-forbidden.
  • The same account is signed in to Chopin in a browser, on this server, since the server last started. Chopin's repository tools run with that sign-in's GitHub App token, never with the connection's bearer. Without one, attach is refused with sign-in-required; sign in at the server's origin and attach again.
  • That sign-in also passes instance admission and has push or administration access to the repository.
  • No other client holds the document's Planner (see below).

A client attached with a machine's Planner credential passes the same checks with the machine's own GitHub authorization instead of a browser sign-in, so it is never refused with sign-in-required and signing out of a browser does not release it. Its credential must name the document it attaches; any other is refused with repository-forbidden.

These are the checks every Planner owner passes. A document has one Planner at a time. While a client is attached, any other attach for that document is refused with planner-attached, including one from the same account, and the message names the holder's GitHub login:

octocat is attached as this document's Planner. A document has one Planner at a time.

When the connection closes, whether by detach or a dropped socket, its account keeps the ownership for a grace period, 2 minutes unless the operator sets PLANNER_ATTACH_GRACE_MS. During it the same account can attach again and keeps the ownership; another account is refused with planner-attached, and the message says until when the holder keeps it:

octocat detached from this document's Planner and holds it until 2026-10-10T19:42:00.000Z unless they reattach. A document has one Planner at a time.

When the grace period ends the ownership is released and any eligible account can attach. A server restart releases every attachment and ownership at once; reattach after it.

The server checks the account's admission and push or administration access again before every start and every turn, and every 5 minutes while the connection is attached. Once either is lost, or the document is archived or deleted, it sends refused with access-revoked and closes the connection with 4403; the turn that triggered the check fails. A GitHub outage during a periodic check leaves the connection open.

The ownership also ends, without a grace period, when the browser sign-in it rests on ends or the document's Planner is reset. The server then sends refused and closes an attached connection at once, and any eligible account, including the same one, can attach again immediately:

Cause Refusal code Close code
The owner's browser sign-in signs out or expires sign-in-required 4401
The machine whose credential attached it is disconnected, or GitHub refuses its authorization access-revoked 4403
Chopin started it on a machine and moved it to another, or stopped it: a new machine choice or favorite, the document unused for 30 minutes (a Planner waiting on a question or a live or paused run is using it), or the machine lost push access access-revoked 4403
A repository writer resets the document's Planner access-revoked 4403
The stored ownership no longer names this attachment's sign-in access-revoked 4403

The same checks before every start and turn, and the periodic one, also find an expired sign-in or a released ownership that nothing reported; an expired sign-in is reported as sign-in-required even when the stored ownership already reads as released. An @chopin message or invoke_planner call makes the same check first, so a connection it finds stale is closed this way and the request is refused as without an attached Planner. An attach that is still in progress when one of these causes lands, including a reattach within the grace period, is refused with the same code and close code instead of attaching; a fresh attach whose sign-in ends while the server checks it is refused with sign-in-required (4401). The message says what happened:

The Chopin browser sign-in that made octocat this document's Planner ended, so the Planner was released. Sign in to Chopin at https://chopin.example in a browser as octocat, then attach again.
A repository writer reset this document's Planner, so octocat is no longer attached. Attach again to resume.
This document's Planner ownership was released, so octocat is no longer attached. Attach again to resume.
Refusal code Close code Meaning
unsupported-version 4426 The server speaks another version
repository-forbidden 4403 The account cannot push to or administer the document's repository
sign-in-required 4401 The account has no live Chopin browser sign-in, or it ended
planner-attached 4409 Another client holds the document's Planner; message names its login
document-unavailable 4404 No document matches, or it is being deleted
document-archived 4404 The document is archived
access-revoked 4403 Access or the Planner ownership was lost after attaching (see above)
unavailable 1011 The server could not check the document

Without an attached Planner

On a server running HARNESS=remote, an @chopin message in the browser and an MCP invoke_planner call for a document with no attached client are refused with the code planner-not-attached, and nothing is posted or queued. A document whose Planner is within its grace period counts as unattached, and so does one whose attached connection the request finds stale: its owner's browser sign-in has ended or the stored ownership no longer names it. The request closes that connection before it is refused.

When a paired machine is starting the document's Planner, the request first waits up to 30 seconds for it to attach, and is refused only if it has not:

This document's Planner is still starting on @<login>'s machine. Try again in a moment.

If no machine is starting one, the message says how to get one. Where Chopin runs Planners on paired machines:

No Planner is attached to this document. Connect a machine that can run Planners (start `chopin connect` with `--planner-command`), or choose one with the machine button in Chat.

Elsewhere it names the document URL:

No Planner is attached to this document. To attach one, connect a Planner client to <document URL> over the agent connection from a session in a checkout of <owner/name>; that session becomes this document's Planner.

Local agent MCP shows how invoke_planner returns it. Once a client is attached, both arrive as turns on its connection.

Server to client

{ "type": "start", "session": "", "mode": "planner" }
{ "type": "turn", "session": "", "turn": "", "prompt": "", "instructions?": "", "model?": "", "tools": [], "responseFormat?": {} }
{ "type": "abort", "session": "", "turn": "" }
{ "type": "host-tool-result", "session": "", "turn": "", "toolCallId": "", "output": json, "isError?": true }
{ "type": "input-result", "request": "", "status": "answered", "value?": json, "message?": "" }
{ "type": "stop", "request": "", "session": "", "run?": "" }
{ "type": "resume", "request": "", "session": "", "run?": "" }
{ "type": "destroy", "session": "" }
{ "type": "error", "code": "malformed-message", "message": "" }
  • start opens a Planner session. mode is planner for a Planner turn session or isolated for a background job's session, which may offer no tools of its own and gets no Decisions. Answer within 10 seconds. A session refused with start-failed or not answered in time gets destroy, and any question asked for it meanwhile is withdrawn and answered cancelled.
  • turn runs one turn. tools lists at most 256 of Chopin's host tools as { "name": "", "description?": "", "inputSchema?": {json schema} }; description may be 64,000 characters. instructions is the Planner's system prompt for the turn. model is set only when the operator configured MODEL. responseFormat is { "type": "text" } or { "type": "json", "schema?": {}, "name?": "", "description?": "" }; for json, the turn's text must be that JSON.
  • abort stops the turn. Answer with turn-end; after 10 seconds the server ends the turn anyway. The server sends no host-tool-result for the turn's calls after abort, so settle any call still waiting as failed.
  • host-tool-result answers a host-tool-call. output is the tool's result as JSON. A failed call has "isError": true and output { "error": "<message>" }; that includes a tool that threw, invalid input, a name outside the turn's tools, a call for a turn or session that has ended, and a result too large for one frame. A call Chopin's permission policy refuses has output { "type": "execution-denied", "reason?": "" }.
  • input-result answers an input-request (see Questions).
  • stop and resume are Stop Planner and Resume Planner: pause or resume every workflow run of the session, or only run. Answer with control-result within 10 seconds.
  • destroy ends the session; abandon its turn, its waiting host calls, and its questions.
  • error precedes a close for a protocol violation (see Closing).

Client to server

{ "type": "started", "session": "" }
{ "type": "start-failed", "session": "", "message": "" }
{ "type": "tools", "session": "", "turn": "", "names": [] }
{ "type": "part", "session": "", "turn": "", "part": {} }
{ "type": "host-tool-call", "session": "", "turn": "", "toolCallId": "", "toolName": "", "input": "{}" }
{ "type": "turn-end", "session": "", "turn": "", "status": "finished", "message?": "" }
{ "type": "input-request", "request": "", "session": "", "workflowRunId?": "", "workflowStageId?": "", "rootRunId?": "", "block?": { "index": 0, "digest": "sha256:<64 hex>" }, "input": {} }
{ "type": "input-cancel", "request": "" }
{ "type": "runs", "session": "", "active": [], "paused": [], "cards": [] }
{ "type": "control-result", "request": "", "ok": true, "message?": "" }
{ "type": "detach" }
  • started or start-failed answers start. Cut message here, in turn-end, control-result, and an error part to 4000 characters: a longer one is a malformed message and closes the connection.
  • tools declares, before calling any of them, the client's own tools this turn may call (at most 256). A name may not repeat a host tool's name. An isolated session must declare none.
  • part streams the turn (see Stream parts).
  • host-tool-call runs one of the turn's host tools in the server; input is its arguments as a JSON string. A toolCallId may be used once per turn, whether by a host-tool-call or a tool-call part, including after its call has finished or was answered with an error result; reusing one fails the turn without running the tool again. A name outside the turn's tools is answered with an error result.
  • turn-end ends the turn: finished, failed (with message as the error shown), or aborted. Every turn needs exactly one. A host tool still running in the server when the turn ends is stopped, and a Decision it opened is withdrawn.
  • input-request and input-cancel ask and withdraw questions. A request id may not repeat on the connection, even after its question has its input-result. The server remembers every open id and the last 1,024 ids it received; a remembered id sent again is a protocol violation that closes the connection. Use fresh ids, such as UUIDs.
  • runs reports the session's workflow runs whenever they change. Report them before turn-end so the server keeps the session while runs are live. Reports only change run cards and Chat notices; they never start a Planner turn. To have the Planner act on a run that ended, call invoke_planner with as: "system" and a GitHub token of the account holding the Planner; see Automatic follow-ups.
  • control-result answers stop or resume.
  • detach closes the connection with code 1000. The account keeps the document's Planner for the grace period in Ownership.

Messages for a session or turn that has ended are ignored, except a host-tool-call, which gets an error result (The turn has ended. or The session has ended.).

Stream parts

part.part is one of these AI SDK HarnessV1StreamParts, without metadata fields:

{ "type": "stream-start", "modelId?": "" }
{ "type": "text-start", "id": "" }
{ "type": "text-delta", "id": "", "delta": "" }
{ "type": "text-end", "id": "" }
{ "type": "reasoning-start", "id": "" }
{ "type": "reasoning-delta", "id": "", "delta": "" }
{ "type": "reasoning-end", "id": "" }
{ "type": "tool-input-start", "id": "", "toolName": "", "providerExecuted?": true, "dynamic?": true, "title?": "" }
{ "type": "tool-input-delta", "id": "", "delta": "" }
{ "type": "tool-input-end", "id": "" }
{ "type": "tool-call", "toolCallId": "", "toolName": "", "input": "{}", "providerExecuted": true, "dynamic?": true }
{ "type": "tool-result", "toolCallId": "", "toolName": "", "result": json, "isError?": true, "preliminary?": true, "dynamic?": true }
{ "type": "finish-step", "finishReason": { "unified": "stop", "raw?": "" }, "usage": {} }
{ "type": "finish", "finishReason": { "unified": "stop", "raw?": "" }, "totalUsage": {} }
{ "type": "error", "error": "" }
  • tool-call and tool-result parts are for the client's own declared tools only, and tool-call must say "providerExecuted": true. A tool-result must follow its own tool-call. Anything else fails the turn as a tool boundary failure, and a tool-call reusing a toolCallId fails it as a repeated call. Host tools are called with host-tool-call; the server emits their call and result parts itself.
  • tool-input-start may name a host tool (its input streams before the host-tool-call) or a declared own tool.
  • A failed own tool's tool-result may carry a structured tool error as its result.
  • finishReason.unified is one of stop, length, content-filter, tool-calls, error, other. usage and totalUsage are { "inputTokens": { "total?", "noCache?", "cacheRead?", "cacheWrite?" }, "outputTokens": { "total?", "text?", "reasoning?" } } with non-negative integers.
  • error fails the turn with that text.

Structured tool errors

An own tool that fails may say why in a form Chat can show. Send its tool-result with "isError": true and, as result, this envelope or its JSON text:

{ "error": { "code": "", "kind?": "", "message": "" } }
  • code is a stable machine identifier, such as no-repository-credential.
  • kind is an optional display name, such as NoRepositoryCredential.
  • message is human-readable text of 1 to 4000 characters.

code and kind are plain identifiers of 1 to 128 characters: an ASCII letter or digit, then ASCII letters, digits, ., _ or -. They cannot hold spaces, quotes, : or =.

The envelope has exactly these fields. @chopin/agent-link exports it as toolErrorSchema and ToolError. It is a convention, not a new wire shape: result remains any JSON, and an envelope that does not match is shown as an ordinary result. Chopin recognises the envelope only on a failed, final result; a successful or preliminary result that contains error is shown as it is.

Work details then shows the call as Failed with kind, or code when there is no kind, and message as plain text instead of the JSON. Chopin masks credential assignments, including every proxy setting such as http_proxy or HTTPS_PROXY, token shapes such as ghp_… and Slack xox…- tokens, and URL credentials before it stores or shows them. The URL rule is deliberately broad, so it masks too much rather than too little. After any ://, whatever comes before it, and after a protocol-relative // that starts the text or follows whitespace, a quote, (, <, =, [, a comma or a colon (so not a // inside a path), it finds the last at-sign within 8,192 characters and masks everything between them, user name included: https://alice:hunter2@host shows as https://[redacted]@host. It reads the colon, slashes and at-sign in any spelling a URL parser or a later decoding step would accept: JSON, JavaScript (\u{40}, %u0040), Python and C (\U00000040, \x40, \100) escapes behind any run of backslashes, percent-encoding at any depth (%40, %2540, …), and HTML entities with or without their semicolon. As URL parsers do, it reads a backslash as a slash and lets tabs and line breaks come before each slash, as in https:\\alice:hunter2@host. Only that window bounds the scan: spaces, quotes, slashes, line breaks and later schemes do not end it, so a password holding :// is masked whole, and text that merely contains a later @, even another URL, can be masked too. Only a short plain user name with no password, such as git in ssh://git@github.com, stays visible. It masks message as plain text, even when it holds JSON. It shows code and kind as written, and does not recognise an envelope whose code or kind the masking would change; that envelope is shown as an ordinary result. The masking is not exhaustive; keep credentials out of the envelope. Known limits: it does not recognise a fullwidth at-sign (@), a separator split by zero-width or soft-hyphen characters, which no URL parser accepts, a tab or line break written as an escape or entity rather than as the character itself, a Python named escape such as \N{COMMERCIAL AT}, or percent-encoding of an escape's own characters, such as %25%34%30 for %40.

This masking applies only to what Chat stores and shows. It does not change what crosses the connection: host-tool-result.output reaches the client exactly as the host tool returned it, and a failed host call keeps its { "error": "<message>" } output.

Questions

An input-request carries one question call in input, and becomes Decisions in the document. It has no time limit: it waits until members answer it or it is withdrawn (the client sends input-cancel, the session ends, or the connection closes), and only then gets its input-result:

{ "method": "questionnaire", "params": { "questions": [{ "question": "", "header": "", "options": [{ "label": "", "description": "", "preview?": "" }], "multiSelect?": true }] } }
{ "method": "confirm", "title": "", "message": "" }
{ "method": "select", "title": "", "choices": [] }
{ "method": "input", "title": "", "placeholder?": "" }
{ "method": "editor", "title": "", "initial?": "" }

A questionnaire has 1 to 16 questions of up to 32 options; preview may be 16,000 characters; select offers up to 32 choices. The document's own limits still apply. Text is stored verbatim and shown as Markdown; see Card text.

block names the block of the document the question concerns, by the index and digest the Planner's latest read_plan or edit_plan result gave it. Its cards go directly after that block and are linked to it. When that block has changed since, its digest now repeats, or it is itself a decision card, the cards go at the end of the document, not linked to any passage, as they do without block. A Planner running in Chopin's own process (HARNESS=atomic) names the block the same way, in the options of its host input request.

input-result is { "type": "input-result", "request": "", "status": "", "value?": json, "message?": "" }. status and value by method:

status questionnaire value confirm select input, editor message
answered { "answers": [answer for every question], "cancelled": false } boolean choice or null text absent
expired { "answers": [], "cancelled": true } false null null absent
cancelled { "answers": [answered questions only], "cancelled": true } false null null absent
failed absent absent absent absent why
  • answered: members answered every question. confirm is true only for Yes. select is the chosen string from choices, or null when a member wrote an answer instead. input and editor are the text a member wrote.
  • expired: an earlier Chopin closed the question unanswered after a fixed time. Chopin no longer sends it: a question waits until a member answers it or it is withdrawn, however long that takes. A client may still accept it from an older server, where the cards stayed in Decisions marked expired and no partial answers were returned.
  • cancelled: a member cancelled a question, the client sent input-cancel, the question was withdrawn before it was asked, the session ended, or the connection closed. Open cards are withdrawn. When a member cancelled one question of a questionnaire after others were answered, answers keeps those others; it is empty in every other case. The package client settles a question withdrawn before it was sent, or one still open when the connection closes, with a local cancelled result that has a message and no value.
  • failed: the request could not become Decisions, for example in an isolated session or with 16 requests already open on the connection.

A questionnaire answers array lists only answered questions, in question order; an unanswered question has no entry. Each entry is one of:

{ "questionIndex": 0, "question": "", "kind": "option", "answer": "", "preview?": "" }
{ "questionIndex": 0, "question": "", "kind": "multi", "answer": null, "selected": [""] }
{ "questionIndex": 0, "question": "", "kind": "custom", "answer": "" }
{ "questionIndex": 0, "question": "", "kind": "chat", "answer": "" }
  • questionIndex is the question's 0-based position and question its verbatim text.
  • option: a single-select question answered with one offered option. answer is its label; preview is that option's preview, present only when it had one.
  • multi: a multiSelect question answered with offered options. selected lists their labels; answer is null.
  • custom: a member wrote the answer to a single-select question whose options have no preview. answer is the text, or the labels of the options members added, joined by a comma and a space.
  • chat: the same written answer for a multiSelect question or one whose options have a preview.

A workflow-stage question names its workflowRunId and, when it differs, the rootRunId of the run that owns it. While the latest runs report lists that root as paused, an answer is held and sent only once the root is no longer paused, so a paused run stays stopped.

runs.cards are run cards as Chat shows them, at most 64. Each runs is a complete report: it lists every card of the session. A full card is:

{ "id": "", "name": "", "status": "running", "started": 0, "updated": 0, "ended?": 0, "stages": [], "earlierStages?": 0, "waiting": 0 }

status is one of running, waiting, paused, finished, blocked, failed, stopped; times are whole seconds since the epoch; waiting counts open questions. Up to 64 stages, the most recent, are { "id": "", "name": "", "parentIds?": [], "kind?": "tool", "status": "", "started?": 0, "ended?": 0 } with status one of pending, running, awaiting_input, paused, blocked, completed, failed, skipped, stopped. earlierStages counts the stages the run reached before the listed ones; Chat lists every reported stage when someone expands the card and says how many it was not sent. Optional parentIds names the parent stages by their IDs in this report (at most 64 IDs, each 1–128 characters). Send these links for nested work; older reports without them remain valid on version 1. Chat renders unfinished descendants of stopped, failed, blocked or skipped stages as stopped, while preserving finished outcomes. A stopped, failed or blocked run stops all unfinished stage displays even without parent links. active and paused list at most 64 root run IDs. List a launched run in active from the moment the workflow tool admits it, or its root reports running, until its root run ends: Atomic delivers activity and lifecycle events in no shared order, so a run can start before its activity does. A finished card is finished only with ended, the time its root run ended; without it, Chat shows the run as still live and posts no finish notice. When the connection closes, the server shows the reported live runs as stopped.

A full card's stages replace every stage Chopin stores for the run, unless it has none (see Run stage deltas). Chopin stores all of a run's stages, not only those a card lists, and keeps them across reconnects and restarts until Chat stops showing the run's card, when a later run replaces ended cards.

Run stage deltas

With the run-stage-deltas feature, a card may instead be a delta card, with stagesDelta and without earlierStages:

{ "id": "", "name": "", "status": "running", "started": 0, "updated": 0, "ended?": 0, "stagesDelta": true, "stages": [], "waiting": 0 }
  • stagesDelta is always true. A delta card's other fields replace the card's, as a full card's do.
  • stages holds only the run's new and changed stages, in the stage shape above, with no count limit; only the frame's 2 MiB bounds them. Chopin adds them by id within the run: a known id is replaced whole, where it stands, so an optional field left out is gone; a new one follows the run's other stages, in the order sent. A repeated id keeps its last value. Stages a delta does not name are kept, and "stages": [] changes none.
  • The run keeps the earlierStages of its last full card.
  • A delta never removes a stage. To start a run's stages over, send a full card with the new first stages, then delta cards.
  • A full card with "stages": [] for a run that already has stages keeps them, with or without this feature. Its other fields, such as status and the times, change the card as usual, and its earlierStages is ignored, so a client that reattaches can report an ended run whose stages it no longer holds without wiping the ones Chopin stores. A full card with any stages still replaces them all.
  • parentIds may name any stage of the same run, sent in this message, an earlier one or a later one. Chopin refuses no parent ID; Chat shows a stage whose parent it does not know at the top level until the parent arrives, and applies the stopped rule above over all of the run's stages.
  • A report too large for one frame goes as several runs in a row, each a complete report with the same active, paused and cards, splitting the stages between them. Make every card of every part after the first a delta card ("stages": [] when nothing is left for it), so no full card replaces the run's stages twice. The package client's reportRuns does this.
  • After each attach, send every stage of each run once, then only what changes; adding a stage Chopin already stores again changes nothing.

A server that did not list run-stage-deltas in attached.features, including every server from before features, refuses a delta card as malformed-message and closes the connection with 1008. Send such a server full cards: the 64 most recent stages, with earlierStages counting the rest.

Card text

A question's text is Markdown, and so is an option's description. The first line of question is the card's heading; everything after the first line break is the body beneath it:

{
	"question": "Confirm the earlier answers\n\n- Storage: PostgreSQL\n- Rollout: a small pilot",
	"header": "Confirm",
	"options": [{ "label": "Confirm all", "description": "" }]
}
  • The heading takes inline Markdown only: code spans, emphasis and links. A line that only looks like a list or heading, such as 1. Ship it?, stays text. Keep it short; it is the card's bold title.
  • The body, and an option description of more than one line, take the Markdown Chat renders: paragraphs, lists, block quotes, code and links. A line break is a line break. A one-line description reads as one phrase, like the heading.
  • A fence named mermaid (```mermaid) is drawn as a diagram by the same renderer, theme and strict security level the document uses, held to a bounded height until the reader expands it. Source Mermaid cannot draw is shown as code.
  • An image shows only when it is this document's own upload: ![alt](/images/<sha256>.<ext>), the path upload_image returned for the same document. Alternative text is required. Any other image, remote, data: or uploaded to another document, shows its alternative text instead.
  • Raw HTML is shown as the text it is, never rendered.

Text without a line break is a heading alone, as it always was, so a client needs no feature check: an older Chopin shows the same text unformatted.

Closing

Close code Sent after Meaning
1000 detach The client detached
1001 nothing No pong or message within the timeout
1008 error with malformed-message or protocol Unparseable, invalid, or out-of-order message
1009 error with message-too-large A frame over 2 MiB
1011 refused with unavailable The server could not check the document
4401 refused sign-in-required
4403 refused repository-forbidden or access-revoked
4404 refused document-unavailable or document-archived
4409 refused planner-attached
4426 refused unsupported-version

protocol covers a message before attach is answered, a second attach, and a repeated input-request id the server remembers. When the connection closes for any reason, the server ends a running turn with an error, stops the turn's host tools still running, withdraws open questions and the Decisions those tools opened, and forgets the connection; reattaching starts new sessions. The account keeps the document's Planner ownership for the grace period described in Ownership. A server never replays an interrupted turn.

A client that receives a frame it cannot parse should close with 4400 and reason malformed-message. Node.js's WebSocket lets a caller close only with 1000 or 3000 to 4999, so a client cannot answer with 1008. The package client does this and settles everything still waiting on the connection at once.

Planner backend commands

A paired machine can run a document's Planner without a browser sign-in. The chopin CLI starts a Planner backend command for each document it is given and hands it a credential; the backend attaches over this connection like any client.

chopin connect --planner https://chopin.example/octo-org/score/plan \
  --planner-command "my-planner --flag" --agent copilot

--planner may repeat. --planner-command is split into words as a POSIX shell would, without expanding anything: single and double quotes, also inside a word as in --label="two words", and backslash escapes outside single quotes; an unmatched quote is refused. CHOPIN_PLANNER_COMMAND may hold the command as a JSON array of words instead. The command runs in the checkout of the document's repository, in its own process group: the one --checkout named, else a clone in <base dir>/repos/<owner>/<repo> that the connector makes once and fetches again before each launch. Its standard output and error go to the connector's a line at a time, with its credential, your --env values and anything that looks like a token replaced with [redacted]; a line too long to mask is left out, with a note in its place.

Environment

Each launch gets the connector's environment, your --env values, and these:

Variable Value
CHOPIN_URL The server's origin.
CHOPIN_DOCUMENT The document as given to --planner, or its URL when Chopin chose this machine.
CHOPIN_PLANNER_TOKEN This launch's credential.
CHOPIN_CHECKOUT The absolute root of the document's repository's checkout or clone.
CHOPIN_WORKTREE The command's own worktree path, <base dir>/planners/<owner>/<repo>/<document id>.
CHOPIN_GITHUB_TOKEN The machine owner's token from Chopin's GitHub App, for pushing and draft pull requests.
CHOPIN_GITHUB_TOKEN_EXPIRES_AT When that token expires, in ISO 8601.
CHOPIN_GITHUB_TOKEN_FILE A file (mode 0600) that always holds the current token.

CHOPIN_WORKTREE stays the same across launches. The connector makes its parent folders but never creates or removes the worktree itself. The command does, for example:

git -C "$CHOPIN_CHECKOUT" worktree add "$CHOPIN_WORKTREE" -b <branch>

The connector renews the GitHub token in the file 25 minutes before it expires, so read the file before each use. The three CHOPIN_GITHUB_TOKEN variables are absent when Chopin gives no token; see Which GitHub account pushes.

Credential

The connector asks for a credential just before every launch:

POST /api/connector/planner
Authorization: Bearer <machine token>
{ "document": "<document id or URL>" }

200 { "token": "chopin_p_…", "expiresAt": <milliseconds since the epoch>, "document": { "id": "", "url": "" } }

Chopin issues it only when it runs HARNESS=remote with AGENT on and not AUTH_MODE=local, where machines hold no GitHub authorization of their own (otherwise 404), the machine is still paired and authorized (otherwise 401 machine-revoked or machine-reauthorize), and the machine's owner, checked with the machine's own GitHub authorization, can push to or administer the document's repository (otherwise 403 repository-forbidden; 404 not-found for no such document, 409 document-archived for an archived one). The credential can open connections to that one document for 15 minutes. A connection it opened does not depend on it afterwards: the server rechecks the machine as it rechecks any owner. Chopin keeps only its hash, in memory, so a server restart forgets it as it releases every attachment.

Attached this way, the document's Planner owner is a login of the machine's owner that no browser holds, whose GitHub token is the machine's own; Chopin's repository tools act with it.

What the backend must do

  • Read CHOPIN_URL, CHOPIN_DOCUMENT and CHOPIN_PLANNER_TOKEN from its environment. The token is never in its arguments; do not log it.
  • Keep any worktree of its own at CHOPIN_WORKTREE.
  • Open /api/v1/agent on CHOPIN_URL offering the chopin.agent.v1 subprotocol, with Authorization: Bearer <CHOPIN_PLANNER_TOKEN>, and attach to CHOPIN_DOCUMENT.
  • On HTTP 401 at the upgrade, or when the connection closes for any reason other than its own detach, exit. Until the credential's 15 minutes have passed a backend may instead reattach with it after 1001 or a dropped socket; after that the credential is refused and it must exit. Chopin forgets a credential once it has expired.
  • Exit on SIGTERM. On Ctrl-C the connector sends it to the backend's process group and, 5 seconds later, kills whatever is left in that group, including processes the backend started that ignored it. A backend that exits by itself has its group stopped the same way before it is started again. On Windows the connector ends the backend's process tree with taskkill /T /F instead.

The connector starts a backend that exited again, with a fresh credential, after the reconnect backoff (up to a second at first, doubling to at most five minutes, and starting over after a backend ran for a minute). When the credential request is refused because the machine was revoked or must be paired again, or Chopin refuses the document, the connector stops that document's backend and says why. A backend refused with planner-attached (4409) because an earlier connection of the same machine still counts as attached is started again on that backoff until the server notices that connection is gone (up to the 120-second timeout in Pings); a closed connection's grace period does not block the same machine.

A backend Chopin asked the machine to run (see Run a Planner on this machine) is started again only once Chopin's Planner poll still asks for it. Chopin closes the link of a Planner it no longer asks for, so the backend can exit before the poll's answer arrives; the connector then waits for that answer, and stops the backend for good when the answer leaves its document out. When a document is renamed, the poll's next answer names it at its new address; the connector shows that address in chopin status and its output, and starts the backend there next time, without restarting a backend that is running.