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.
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-Protocolmust offerchopin.agent.v1, the subprotocol this page describes. It may offer others too. The server always answers withchopin.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.v1Bun'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"] }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.
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-deltasThe 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 |
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_MSto 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.
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.
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?": "" } }
versionis the protocol version the client speaks; this page is version1.documentis a document id or its Chopin URL, at most 2048 characters.checkoutdescribes 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.
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,
attachis refused withsign-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 |
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.
{ "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": "" }
startopens a Planner session.modeisplannerfor a Planner turn session orisolatedfor a background job's session, which may offer no tools of its own and gets no Decisions. Answer within 10 seconds. A session refused withstart-failedor not answered in time getsdestroy, and any question asked for it meanwhile is withdrawn and answeredcancelled.turnruns one turn.toolslists at most 256 of Chopin's host tools as{ "name": "", "description?": "", "inputSchema?": {json schema} };descriptionmay be 64,000 characters.instructionsis the Planner's system prompt for the turn.modelis set only when the operator configuredMODEL.responseFormatis{ "type": "text" }or{ "type": "json", "schema?": {}, "name?": "", "description?": "" }; forjson, the turn's text must be that JSON.abortstops the turn. Answer withturn-end; after 10 seconds the server ends the turn anyway. The server sends nohost-tool-resultfor the turn's calls afterabort, so settle any call still waiting as failed.host-tool-resultanswers ahost-tool-call.outputis the tool's result as JSON. A failed call has"isError": trueandoutput{ "error": "<message>" }; that includes a tool that threw, invalid input, a name outside the turn'stools, 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 hasoutput{ "type": "execution-denied", "reason?": "" }.input-resultanswers aninput-request(see Questions).stopandresumeare Stop Planner and Resume Planner: pause or resume every workflow run of the session, or onlyrun. Answer withcontrol-resultwithin 10 seconds.destroyends the session; abandon its turn, its waiting host calls, and its questions.errorprecedes a close for a protocol violation (see Closing).
{ "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" }
startedorstart-failedanswersstart. Cutmessagehere, inturn-end,control-result, and anerrorpart to 4000 characters: a longer one is a malformed message and closes the connection.toolsdeclares, 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.partstreams the turn (see Stream parts).host-tool-callruns one of the turn's host tools in the server;inputis its arguments as a JSON string. AtoolCallIdmay be used once per turn, whether by ahost-tool-callor atool-callpart, 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'stoolsis answered with an error result.turn-endends the turn:finished,failed(withmessageas the error shown), oraborted. 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-requestandinput-cancelask and withdraw questions. Arequestid may not repeat on the connection, even after its question has itsinput-result. The server remembers every open id and the last 1,024 ids it received; a remembered id sent again is aprotocolviolation that closes the connection. Use fresh ids, such as UUIDs.runsreports the session's workflow runs whenever they change. Report them beforeturn-endso 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, callinvoke_plannerwithas: "system"and a GitHub token of the account holding the Planner; see Automatic follow-ups.control-resultanswersstoporresume.detachcloses 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.).
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-callandtool-resultparts are for the client's own declared tools only, andtool-callmust say"providerExecuted": true. Atool-resultmust follow its owntool-call. Anything else fails the turn as a tool boundary failure, and atool-callreusing atoolCallIdfails it as a repeated call. Host tools are called withhost-tool-call; the server emits their call and result parts itself.tool-input-startmay name a host tool (its input streams before thehost-tool-call) or a declared own tool.- A failed own tool's
tool-resultmay carry a structured tool error as itsresult. finishReason.unifiedis one ofstop,length,content-filter,tool-calls,error,other.usageandtotalUsageare{ "inputTokens": { "total?", "noCache?", "cacheRead?", "cacheWrite?" }, "outputTokens": { "total?", "text?", "reasoning?" } }with non-negative integers.errorfails the turn with that text.
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": "" } }
codeis a stable machine identifier, such asno-repository-credential.kindis an optional display name, such asNoRepositoryCredential.messageis 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.
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.confirmistrueonly for Yes.selectis the chosen string fromchoices, ornullwhen a member wrote an answer instead.inputandeditorare 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 sentinput-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,answerskeeps 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 localcancelledresult that has amessageand novalue.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": "" }
questionIndexis the question's 0-based position andquestionits verbatim text.option: a single-select question answered with one offered option.answeris its label;previewis that option'spreview, present only when it had one.multi: amultiSelectquestion answered with offered options.selectedlists their labels;answerisnull.custom: a member wrote the answer to a single-select question whose options have nopreview.answeris the text, or the labels of the options members added, joined by a comma and a space.chat: the same written answer for amultiSelectquestion or one whose options have apreview.
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.
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 }
stagesDeltais alwaystrue. A delta card's other fields replace the card's, as a full card's do.stagesholds 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 byidwithin the run: a knownidis 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 repeatedidkeeps its last value. Stages a delta does not name are kept, and"stages": []changes none.- The run keeps the
earlierStagesof 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 asstatusand the times, change the card as usual, and itsearlierStagesis 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. parentIdsmay 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
runsin a row, each a complete report with the sameactive,pausedand 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'sreportRunsdoes 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.
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
descriptionof 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:
, thepathupload_imagereturned 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.
| 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.
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.
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.
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.
- Read
CHOPIN_URL,CHOPIN_DOCUMENTandCHOPIN_PLANNER_TOKENfrom 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/agentonCHOPIN_URLoffering thechopin.agent.v1subprotocol, withAuthorization: Bearer <CHOPIN_PLANNER_TOKEN>, andattachtoCHOPIN_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 /Finstead.
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.