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-deltas, decision-option-previewsThe 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 |
decision-option-previews |
An option's preview as its own visual, and the upload_plan_image host tool |
resumable-sessions |
Resuming the sessions, runs and questions of a link that dropped unexpectedly |
restart-notice |
A restarting message before the server closes the link with 1012, and the client rule that follows it |
capability-catalog |
A capabilities report of the models, thinking levels and commands the client offers, and a person's choice of model and thinking level in the turn |
attachments |
The images and text files a person sent with a message, in the turn's attachments |
session-forks |
A fork message to branch a thread's Planner session from an earlier session and turn, multiplexed over the same connection |
steer-turn |
A steer message: a person's message for the turn the client is running, read at its next step |
retry-turn |
A turn's message, the Chat message it answers, and retry when it runs a failed message again |
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): a resumable-sessions link
keeps its work, and its account keeps the document's Planner, as
Resuming and Ownership describe. 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, except inside a
capability catalog, which is read as written. 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, responseFormat.name |
1 to 128 |
| short | question header, finishReason.raw |
up to 128 |
| note | message, every title, error, responseFormat.description, question text, option label and description, choices entries, placeholder, initial, run and stage name |
up to 4000 |
| text | prompt, instructions, delta, input, and the model and level fields of the capability catalog: modelId, turn.model, turn.thinkingLevel |
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, and the message names the holder's GitHub login:
octocat is connected to this document. A document has one agent connected at a time.
One exception serves a client that left a connection that died unnoticed. A client
that notices its network failed before Chopin does reconnects at once, while Chopin
still counts its old link as attached (up to the 120-second timeout in
Pings, longer behind a buffering proxy). If both links belong to the same
account and offered resumable-sessions, Chopin answers the new attach with
attached but treats the new link as unconfirmed until its first message. A resume
naming a session the old link holds shows the client is the same one returning and
its old connection is gone: Chopin closes the old link as the unexpected drop it is,
keeps its work, and gives it to the new link, which then resumes as usual. Any other
first message, a resume naming nothing the old link holds, or another account, gets
refused with planner-attached after attached, and the old link is untouched. That
refusal is a decision, not a reason to retry. A document archived or deleted while the new
link waited for its first message is refused document-archived.
A browser-bearer client and a paired machine's backend never take each other's links.
When the connection closes by detach, or a socket drops with no
workflow run or open question for the server to keep, 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 disconnected from this document and keeps it until 2026-10-10T19:42:00.000Z unless they connect again. A document has one agent connected at a time.
When the grace period ends the ownership is released and any eligible account
can attach. The grace period is a lease on the ownership of a connection that is
no longer there, not a timeout on active work: it is 2 minutes by default and at
most one hour (PLANNER_ATTACH_GRACE_MS accepts 0 to 3600000), and it never runs
while a connection is attached.
A link that offered resumable-sessions and dropped unexpectedly while it held a
live or paused workflow run or an open question runs no grace period at all. Its
account keeps the ownership, as the document's disconnected Planner, for as long
as its work is kept: until its client resumes or something
explicit ends it (see What still ends it for good).
Another account is refused with planner-attached and a message that says the
holder's connection dropped and its work is kept until it reconnects:
octocat is connected to this document, and its connection dropped. Its work is kept until it reconnects. A document has one agent connected at a time.
A server restart releases every attachment and ownership at once; reattach after it. What a disconnected link kept does not survive that: see After a server restart.
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 replying, or 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 connected octocat to this document ended, so the connection was released. Sign in to Chopin at https://chopin.example in a browser as octocat, then connect again.
A repository writer reset this document's connection, so octocat is no longer connected. Connect again to resume.
This document's connection was released, so octocat is no longer connected. Connect 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 or the account; retry is set |
Every code is a decision about this account or document, final for a client that is
resuming, except unavailable: Chopin could not decide, so the refusal carries
"retry": true, and such a client keeps what it holds and tries again with the same
resume on its own backoff. No retry is promised to succeed. An HTTP 503 at the upgrade
(GitHub unavailable or rate limiting) is the same kind of refusal. A refusal without
retry, and an error, ends what the client held.
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
planner-not-attached, and nothing is kept for when a client attaches: no message is ever
held for a Planner (see messages while the Planner works).
A document whose Planner is within its grace period, or disconnected with its work kept
(the message then says the machine is offline), 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:
Still connecting to @<login>'s machine. Try again in a moment.
Once the machine's backend has exited before attaching more than once in a row, the refusal says why, from what its connector reported or, for a connector that cannot say or a machine that has not answered in two minutes, from what Chopin saw itself (see Hosted agent), and still only names the login:
Can't connect to @<login>'s machine: <reason>. Try again once that is fixed.
How often it was tried is never said: it connects, or it can't, and why.
If no machine is starting one, the message says how to get one, in plain words: a machine is needed. Where Chopin runs Planners on paired machines:
Connect a machine to get a reply. Choose one with the machine button, or run `chopin connect` on a machine that can reply.
Elsewhere it names the document URL:
Connect a machine to get a reply. Connect an agent client to <document URL> over the agent connection from a session in a checkout of <owner/name>.
A document whose Planner is disconnected, or stepped away within its grace period, is refused with the machine named by its owner's login, never by its own name. It says what happened to the message and what to do, and nothing of keeping it, since nothing is kept for a machine that is away (the machine's own work, its runs and open questions, is kept, and the document says so where it shows the machine):
@<login>'s machine is offline, so this message wasn't sent. Send it again when it's back.
Inside a deploy's hand-over window before the client has resumed, it says:
Still connecting to @<login>'s machine, so this message wasn't sent. Send it again in a moment.
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": "fork", "session": "", "mode": "planner", "from": { "session": "", "turn?": "" } }
{ "type": "turn", "session": "", "turn": "", "prompt": "", "instructions?": "", "model?": "", "thinkingLevel?": "", "attachments?": [{ "id": "", "name": "", "kind": "image", "mediaType": "", "size": 0, "data": "" }], "message?": "", "retry?": true, "tools": [], "responseFormat?": {} }
{ "type": "abort", "session": "", "turn": "" }
{ "type": "steer", "request": "", "session": "", "turn": "", "prompt": "" }
{ "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": "restarting", "reconnectWithinMs": 0 }
{ "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.fork, withsession-forks, opens a thread's Planner session branched fromfrom.session(andfrom.turnwhen named), preserving the conversation, tool results, compaction state and session memory up to that point. Answer withstartedorstart-failedwithin 10 seconds, exactly as forstart. Once started, turns, host tools, questions, runs anddestroymultiplex over the connection bysession. A client that did not offersession-forksnever receivesfork; when a client answersforkwithstart-failed(such as when the source session is no longer held), Chopin falls back tostartwith the transcript up to the thread's root message.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 when the operator configuredMODEL, or, withcapability-catalog, when a person chose a model from the catalog the client reported, which then replaces it.thinkingLevelis sent only withcapability-catalog, and only when a person chose one.attachmentsis sent only withattachments, and only when the message that began the turn carried images or files (see Images and files in a turn).messageandretryare sent only withretry-turn(see Retrying a failed turn).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.steer, withsteer-turn, gives the turn that is running a message a person sent to Chopin while it works. Chopin never holds a message for the next turn, so this is how a person talks to a Planner mid-reply:promptis the message as the model should read it (the sender's handle first,@bo: ...), to be read at the turn's next step, after the tool calls of the step it is in and before its next call to the model, as an ordinary user message in the same turn. Answer withcontrol-resultwithin 10 seconds:ok: trueonce the turn has taken it,ok: falsewith amessagewhen it has not, because the turn has ended or the client has no way to hand it on. A refusal is final: Chat then says the message was not read, and it is not sent again. A client that did not offer the feature never receives one, and a message sent while it works is refused in Chat ("chopin can't take messages while it works on this machine. Stop it to ask now.") rather than kept for later.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). Withresumable-sessions, the client acknowledges everyinput-resultit receives withinput-received(see below).stopandresumeare Stop Planner and Resume Planner: pause or resume every workflow run of the session, or onlyrun. Answer withcontrol-resultwithin 10 seconds; an answer that comes later is still an answer and clears the Stop on the server. Astopmay arrive again for a run it already paused, because the server cannot tell an answer it never received from astopthe client never heard (see Resuming). It pauses, so a repeat changes nothing; answer itok: true. A client that answersok: false, for a run that has since ended, only has the refusal logged for astopsent again.destroyends the session; abandon its turn, its waiting host calls, and its questions. A client offeringsession-forksmay retain the finished session's conversation history so a laterforknamingfrom.sessioncan branch from it, or answerstart-failedonce it has evicted it.restarting, withrestart-notice, precedes a close with 1012 when the server is restarting.reconnectWithinMsis how long, in milliseconds, a new process is expected to take to take over; see When the server is restarting. A client that did not offer the feature never receives it.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" }
{ "type": "resume", "sessions": [{ "session": "", "requests": [""] }] }
{ "type": "input-received", "request": "" }
{ "type": "capabilities", "catalog": {} }
resume and input-received belong to the resumable-sessions feature and are defined in
Resuming after a dropped link and below. capabilities belongs to
capability-catalog and is defined in Capability catalog.
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-received, withresumable-sessions, is{ "type": "input-received", "request": "" }: the client has theinput-resultforrequestand settled its question. Send it on receiving everyinput-result, including a repeated one for a question already settled, and nothing else is needed for it to be idempotent: a client ignores aninput-resultfor a request it already settled, but still acknowledges it. Until it arrives, the server keeps the result, and the session that waits to deliver it, because a result written into a socket that died unnoticed reaches nobody. If the link drops first, the server keeps the result for the client's return and sends it again afterresumedwhenresumestill names the request; a requestresumeno longer names was settled, so the result is let go. Nothing but the acknowledgement releases it: no timer does, and neither does anything else the client sends or answers meanwhile, since such a frame can have crossed the result on the wire. A client with the feature that never acknowledges breaks it, and its session simply stays retained while the link is attached, as any session waiting on its client does, and moves to the outbox when the link closes. A client without the feature never sends it, and one that sends it anyway is aprotocolviolation.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-resultanswersstop,resumeor, withsteer-turn,steer.detachcloses the connection with code 1000. The account keeps the document's Planner for the grace period in Ownership. It ends everything the link held, even withresumable-sessions: only a link that drops without it keeps anything.resume, withresumable-sessions, is the first message afterattached: the sessions the client still holds from a dropped link (see Resuming).
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.).
With capability-catalog, a client tells Chopin what it offers in one message, so that a
document's composer can let a person choose what the client runs. The catalog is one value that
grows by adding fields; adding a kind of capability later needs no new message and no new feature.
Commands already fit it, as listed below. Chopin reads a catalog as it was written. Nothing says
how a model, a thinking level or a command is spelled or how long its text runs, so Chopin refuses
no string for its spelling or its length (a level may be extra high or 低, an id any length,
a name or kind empty) and never changes one. A field it has no use for is ignored and kept,
never refused, and no count bounds the models or commands a client lists. Only the frame's size
bounds a catalog, and only a value of the wrong type (a number where a string is listed, a
string where an array is) breaks a rule here. An odd entry never closes the link.
The one thing Chopin leaves out is what nobody could be asked for. A model whose id is empty is
malformed, because a turn names a model by its id: Chopin skips that entry, keeps every other
model and the link, and logs it. An empty string among a model's thinkingLevels is dropped from
that model's levels in the same way (a model left with none has no reasoning control), and so is a
models.default with an empty id, or an empty thinkingLevel of the default. An empty model or
level can therefore never be chosen, and a turn never carries one.
{ "type": "capabilities", "catalog": {
"models?": {
"available": [{
"id": "", "name": "",
"provider?": { "id": "", "name?": "" },
"description?": "",
"thinkingLevels?": [""],
"inputTypes?": [""]
}],
"default?": { "id": "", "thinkingLevel?": "" }
},
"commands?": [{ "name": "", "description?": "", "kind": "" }]
} }
- When. Send it right after
attached, and after theresumeof a resumable link, which stays the first message. Send it again whenever the client's offer changes, for example after the person signs in to another provider. Each report replaces the earlier one whole: a field left out is no longer offered, and{ "catalog": {} }withdraws everything. A client that offered the feature and does not report offers no choice, the same as a client from before the feature. Sendingcapabilitieswithout having offered the feature is aprotocolviolation, and a catalog whose fields have the wrong type (listed below), amalformed-messageone. The catalog is held with the link, not stored: after a server restart a resumed client sends it again. models.availablelists the models in the order to show them within a provider, as many as the frame holds (Atomic's own list runs past 1,700).idis the exact string the client wants back as a turn'smodel; Chopin never parses, bounds or normalizes it, and skips an entry whoseidis empty.nameis what a person reads.providergroups models in the picker:idgroups,name(default:id) labels the group, and a model with noprovideris listed without a group.descriptionis one line of text. Chopin shows names and descriptions as text, never as Markdown. A model listed twice stays listed twice, in the order given; a choice of itsidis the first entry's.thinkingLevelsare the levels of reasoning this model supports, as strings in the order to show them. Atomic's areoff,minimal,low,medium,high,xhighandmax; other clients may define their own, spelled as they like. Chopin shows every level exactly as written, Atomic's own included (xhigh, not Extra high), its case unchanged, so thatxhighandextra high,ß-fastandSS-fast, andhighandHighare each two levels a person can tell apart, and the turn carries the string they saw. The composer's own first step, which returns the level to the model's own, is called Default, or Model default where the model lists a level of that name, so no level shares its label. A model with none or an empty list has no reasoning control, and the composer hides it. An empty string is not a level: it is dropped. Chopin sends back only a level the model listed.inputTypesis what the model reads:text, andimagewhere it reads images, as strings the client defines (Atomic's owninputlist is sent as it is). Chopin sends a turn an image only when the model the turn names (or the default model, when it names none) listsimagehere, and the composer offers a person an image for a Planner message only then. A model with noinputTypes, an empty list, or no catalog at all reads as text only, so a client from before the field is never sent an image. A text file does not depend on it.models.defaultis what the client runs when a turn names no model or no level. Itsidshould be one ofavailable; the composer shows that model as the document's current one until somebody chooses another. An operator'sMODELtakes its place when it is set. A default with an emptyidis ignored, and an emptythinkingLevelis dropped from it.commandsis reserved for the commands a client offers, such as skills, prompt templates and workflows:name,descriptionandkind, a string the client defines (skill,prompt,workflow). Chopin keeps them with the catalog, as written (order, spelling, repeated entries and a missingdescriptionincluded), and tells the room's composers what the attached client holds in thecommandsofchat:model. They are the attached client's alone: nothing a client reported is offered once it has left, and another machine's are never sent to this document. Send them only when they are real: a client that has none omits the field, and its composer then offers Chopin's own commands only. A person's message that runs one reaches the client as thepromptof an ordinaryturn:/name argsfor askill, apromptor any kind other thanworkflow, as the client reported the name, and a request to start the workflow of that name, with the words after it as its input, for aworkflow. Chopin checks the name and kind against the last report when the message is sent and refuses an unknown one.
A person's choice reaches the client in the next turn: model is the chosen model's id, and
thinkingLevel one of that model's levels (or the default model's, when no model was chosen),
each exactly as the client spelled it. Neither is ever empty: an empty model id or level is
skipped when the catalog is read (above), so no one can choose it, and a model that is absent
means no model was chosen (an operator's empty MODEL reads the same way).
Chopin checks both against the last catalog the client reported when it accepts the choice and
again when the turn starts. A model the catalog no longer lists, or a level its model no longer
lists, is not sent: the turn runs with the client's default, and the document's Chat says so. The
catalog name is saved with the choice so that notice can name it even after it disappears. The
browser's chat:model state includes an optional modelName for that saved name; older choices
without a name use the current catalog's name if known, otherwise the id. Older browsers can ignore
this optional field. A client must still treat an unknown model as an error, because its catalog can have changed
since the report. Without a catalog (an older client, or one that reported none) the composer
offers no choice and thinkingLevel is never sent. The composer says a chosen model is no longer
available until the document's Chat records that fallback on a turn; it does not repeat the record.
The two fields are independent of the protocol version: nothing here changes an existing message
for a client that did not offer the feature, so the subprotocol stays chopin.agent.v1.
A person can send images and text files with a message to the Planner (see Files in Chat). With attachments, the turn that answers it carries them:
"attachments": [{
"id": "", "name": "", "kind": "image", "mediaType": "", "size": 0, "data": ""
}]
kindisimagefor a PNG, JPEG, WebP or GIF, andfilefor a text file. Chopin decides it from the file's bytes before it keeps one, never from its name.mediaTypeis the type it was kept under: one ofimage/png,image/jpeg,image/webporimage/gif, ortext/plainfor every text file whatever it was called, and never HTML or SVG.nameis the file's name as a person sees it (at most 255 characters, with no folder and no control characters); it is a label, not a path, and a client must not use it to name a file on disk without cleaning it again.sizeis the length in bytes, anddatathe same bytes as base64.- Order and bounds. The files are in the order the person sent them, at most 20, and
together at most 1,048,576 bytes before base64, so they always fit inside the frame's
2 MiB with a prompt and the tools. A deployment's own caps are never higher under
HARNESS=remote(CHAT_ATTACHMENT_MAX_BYTESandCHAT_ATTACHMENT_MAX_TOTAL_BYTES).idis new for each turn and says nothing outside it. - Images only where the model reads them. An image is in
attachmentsonly when the model the turn names, or the default model when it names none, listsimagein its cataloginputTypes. Text files are always sent.promptis the message's own text as it always was. - A client that did not offer the feature. It is never sent the field, so its
turnis exactly what it was: the message's text files are written intoprompt, each as<file name="notes.md">, the file's text,</file>, and an image it cannot be given is said in a line of words inprompt(so the model can tell the person it could not see it), not dropped without a word. A client from before the feature, and one that validatesturnstrictly, are unaffected. - Sensitive data. The bytes are what a person chose to share with the room; Chopin sends them only to the client attached to that document, in the turn that answers the message, and keeps no copy in the link. A client should treat them as it treats the prompt.
The field is optional and additive: nothing here changes an existing message for a client that
did not offer the feature, so the subprotocol stays chopin.agent.v1.
A reply can fail part way: the model's provider is down, a request times out, the client loses its model. Chat then shows the failure under the message, and the person who sent it can press Retry. Retry is not a new message. Chat keeps the one message, takes the failed attempt out of the transcript (its partial reply, its tool rows and the failure line) for everyone, and runs the same message's turn again. The model must read that message once and nothing of the attempt that failed.
With retry-turn, every turn that a person's Chat message began names it:
{ "type": "turn", "session": "s2", "turn": "t2", "prompt": "@ana: Hey there!", "message": "0f8c…", "tools": [] }
{ "type": "turn", "session": "s3", "turn": "t3", "prompt": "@ana: Hey there!", "message": "0f8c…", "retry": true, "tools": [] }
messageis the id of the Chat message the turn answers, the same id the room sees. It is absent from a turn no person's message began (a background job, a decision follow-up, an instruction Chopin wrote). When Chopin asks the same session to repair a reply it withheld, that secondturncarries the samemessage, withoutretry.retry: truesays this turn runsmessageagain after an earlier turn for it failed. Itspromptis the message as it was first given, and itsattachmentsthe same files. Before running it, the client drops from whatever it keeps across sessions or turns everything the earlier turns formessageadded: their prompt, the model's partial reply and reasoning, their tool calls and results, and the error. It then runs the prompt as that message's only turn. A client that keeps nothing beyond its session has nothing to drop.- What Chopin already does. A failed turn's session is destroyed, so a retry normally runs in
a fresh
startsession. Itsinstructionscarry the conversation so far without the message (which is the prompt) and without anything of the failed attempt, which is no longer in the transcript. A client that does not offer the feature therefore still gets a clean context for an ordinary retry; only a client that keeps its own memory beyond Chopin's sessions, or a session Chopin kept because it owns a workflow run or an open question, needsmessageandretryto drop the attempt itself. - A thread forked later from the retried message branches from the retried turn, never the failed one: Chopin forgets the fork point the failed attempt recorded.
Nothing here changes an existing message for a client that did not offer the feature, so the
subprotocol stays chopin.agent.v1.
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. - A successful own tool's
tool-resultmay carry the images it made as content blocks in 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 (except the two reading tools; see
Reading a large document), and a failed host call
keeps its { "error": "<message>" } output.
An own tool that made images, such as an image generator, shows them in Chat by
sending, as its final successful result, an object whose content array holds
image blocks beside any text blocks, the shape MCP and Atomic tool results
already have:
{ "content": [
{ "type": "text", "text": "" },
{ "type": "image", "data": "<base64>", "mimeType": "image/png", "alt?": "", "caption?": "" }
] }
datais the image's bytes in standard base64, with or without its=padding;mimeTypeis their type:image/png,image/jpeg,image/webp,image/gif,image/avif,image/bmporimage/x-icon. The usual other names, such asimage/jpg,image/apng,image/x-ms-bmpandimage/vnd.microsoft.icon, are read as the type their bytes are.altsays what the image shows, in words, andcaptionis a line shown with it. Both are optional and kept as given, an empty string included. Chat shows its own words for an image withoutalt.- Extra keys in a block, and an
altorcaptionthat is not a string, are ignored. The JSON text of the same object is read the same way.
Chopin stores each image block it can host, a raster image a browser draws (PNG,
JPEG, WebP, GIF, AVIF, BMP or ICO) whose bytes are of its mimeType and whose
size it can read, as one of the document's own images and as the type its bytes
are. The store is the one upload_image uses, which still takes only a PNG,
JPEG, WebP or GIF. Only after the bytes are
stored does Chat show it, labelled as generated, on the turn's message: every
image a turn makes, from any call, goes on one message, in the order they were
made, and several show as one gallery. Browsers load it from
/images/<sha256>.<ext>, which serves it only to people who can read the
document, with its own Content-Type, X-Content-Type-Options: nosniff and a
sandbox content security policy, so no SVG or HTML is ever served from it.
A block Chopin will not host, SVG and HTML among them, is not shown, and the
message does not leave it unsaid: the turn's message lists refusedImages, one
entry per such block in the order of the result's blocks, duplicates kept, each
with a plain sentence that starts Image couldn't be shown: and says why, and
before, how many of the message's images come before it. The reasons are that
its type is not one Chat shows, that its data is not base64, that its bytes are
not the type it names (or are another type, or are cut short), that its block
has no data or type, that it did not fit one message, that Chopin could not
store it, which gives no detail of the failure, and that it is more pixels than
Chopin can make smaller. An image is never refused for its size: one larger than
the server's MAX_STORED_IMAGE_BYTES, 10 MiB by default, is stored as a smaller
copy of itself, as below. Turns before that saved
Image too large to show (N MB, limit M MB) for such an image, which does not
start with Image couldn't be shown:; a saved refusedImages entry is valid with
either. refusedImages is an optional field that old browsers ignore,
saved with the message; a message may have it without images. The tool's row
in Work details shows the text blocks and how many images the result carried,
never their bytes. A preliminary or failed result shows no images and no
reasons.
@chopin/agent-link exports the block as toolImageSchema. Like the
structured tool error, it is a convention, not a new
wire shape: result is still any JSON, so a client sends it to any server, and a
server that does not know it shows the result as before. A picture has no
size limit of its own: the MAX_STORED_IMAGE_BYTES setting (10 MiB by default, see
Self-hosting) bounds the decoded bytes Chopin stores for every image a
tool made, whichever harness ran the tool, and a larger one is stored as a smaller copy of
itself (at most 4096 pixels on its longest side, then narrower until it fits; a JPEG stays a
JPEG and anything else becomes a WebP, which keeps transparency), the way Slack keeps a
large photo and shows a smaller one. Chat then reads it at Slack's thumbnail widths
(?w=360 and so on, see Storage). Over this connection the whole message,
images included, stays within the 2 MiB message limit as well.
A connection refuses a message over that limit before the server reads it, so
only the client can make an image fit. The @chopin/agent-link client does:
when a tool-result part would be over the limit, it sends each image that does
not fit, largest first and the later of equals first, as a smaller copy of itself
that fits what is left of the message, made with Bun.Image on the client's own
machine, and the parts after it wait for it, so they arrive in order. Only where no
copy can be made (a runtime without Bun.Image, or bytes that do not decode) does it
send the block with data empty and tooLarge set to the bytes the image would have
taken, and Chat then says so beside the images. A block with tooLarge is never stored,
whatever else it holds. A client that does not do this, as before it did, has its
message refused with the generic error whose code is message-too-large,
which names no image, and its link closed with 1009 (see Closing),
and Chat shows no reason for the missing images, since the server never reads
the result; a server that does not know tooLarge leaves such a block out
without saying why. A result that still does not fit with its images left out
is not sent, and its emit throws, as it always did.
read_plan and read_child host results, and MCP read_document's text, are
text a model can page through, not JSON: a short header (document, url,
revision, size), the top-level blocks with their lines and digests, then the
document's own lines, verbatim. One result carries at most 16,000 UTF-8 bytes in
all, header and lists included, because a harness may keep only the head and
tail of a result over 20 KiB. They take optional offset (a 1-based line),
limit (most lines; a result stops sooner when the budget requires, and says
so) and column (a 1-based character in line offset, to continue a line too
long for one result), and name the exact call that continues in a next: header
line. A document over the budget returns an outline and its first part until the
client pages through it; calling each next: returns every character exactly
once. See Reading a large document.
These are the only host results whose host-tool-result.output is not the tool's
own return value: the two tools return their structured view, and the server
sends the client the text its model should read, made from the same view. A client
that read the revision from that JSON must read revision: <n> from the header.
The open_decisions and ask results' current_plan stays JSON, and leaves out
source and blocks when the view is over 8,000 bytes, and says so in
source_omitted.
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 and is an option's own visual; 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. Withresumable-sessions, a question still open when the link drops unexpectedly does not end: see Resuming.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, except where a link with
resumable-sessions dropped unexpectedly and its session is kept.
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.
With the decision-option-previews feature, an option's preview
is the option's own visual: Chopin keeps it as a field of its own and shows it,
framed, under the option's label and description on every card surface
(Decisions, the inline card, the phone sheet, answered cards and the decision
page). Members compare the choices by looking at them, so give every option of
a comparison a visual of the same kind.
previewis one string of Markdown, at most 16,000 characters, stored verbatim. It may hold a Mermaid fence, a small pipe table, a code sketch, or an image:, the document's own upload with the required alternative text. Anything else, such as a remote ordata:image, shows its alternative text. There is no separate image field and no cap on the number of images, but one picture or diagram per option reads best; the option's one-linedescriptionis its caption.- Pictures are held to the option's column and about a third of a screen, and open large from a click on the picture, which never chooses the option. A diagram is bounded until the reader expands it. Where space is tight, as in a decision with criteria, an image is a small thumbnail and other content is left out of the row.
- The option's accessible name stays its label and description; the preview is a
group of its own named
Preview of <label>. - A selected option's answer carries the option's
previewas before.
The upload_plan_image host tool is offered in a turn's tools only to a client
that asked for the feature. It stores an image the Planner made in this
document's own images and returns the Markdown to use, in an option's preview,
a question's body, or the plan:
input: { "data": "<standard base64, at most 16 MiB decoded>", "mimeType": "image/png" | "image/jpeg" | "image/webp" | "image/gif", "alt": "" }
output: { "path": "/images/<sha256>.<ext>", "markdown": "" }
alt is required, one line of at most 200 characters. The same bytes uploaded
twice give the same path. The server never reads a path on the Planner's side:
a remote Planner encodes its own file. A call fails with the usual host tool
error for an image over 16 MiB (one over 1 MiB is stored as a smaller copy of
itself, so it fits a document's images), an unsupported type, bytes that are not the
declared type, an archived document, or a call outside a live Planner turn. The
image loads only for people who can read this document. Chat also shows the
image, with its alt, as a generated image on the turn's message, like the
images a tool made. A workflow stage has no
turn of its own, so from a stage use the MCP upload_image tool with the same
bytes, or pass the Markdown down from a Planner turn.
A server that did not list decision-option-previews in attached.features
joins preview onto the end of the option's description and offers no image
tool; a Chopin that renders card text as Markdown still draws a Mermaid fence or
the document's own image there, just not in a frame of its own.
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 |
| 1012 | restarting, with restart-notice |
The server is restarting |
| 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, except
where resuming keeps them. The account keeps
the document's Planner ownership as described in Ownership: for the
grace period, or with no deadline while a resumable-sessions link's work is kept.
A server never replays an interrupted turn.
A network that fails ends a link without a detach. A lost connection never ends
a client's work by itself. A client that asks for the resumable-sessions
feature can come back without losing what it was doing, however long it
is away: nothing times it out, and the grace period in Ownership does
not apply to it.
- What drops. The socket closes with no
detachand norefused, or the server closes it asunresponsive(see Pings). The turn under way still fails with an error and its host tools stop, as on any close. - What is kept. Each started session that has a live or paused run, or a
question the client asked and nobody has answered, stays as it was: its run
cards keep the status last reported, not
stopped, markeddisconnectedfor the people reading them, its Decisions stay open in the document and can be answered, and an answer given meanwhile is held. Other sessions end as they always did. Chat says nothing of the connection itself: the machine button, its menu and the run cards carry that state, and nothing confirms it came back. Nothing waits on the client in the meantime:@chopinandinvoke_plannerare refused as without an attached Planner, and no other account can attach. The document is not let go either: a Default machine's idle period, a machine that went quiet, and the owner's GitHub credential being renewed end nothing. - Nothing but an explicit end. There is no deadline. The operator's
PLANNER_ATTACH_GRACE_MSleases only a connection that detached, or dropped with nothing to keep. A client may be away for minutes or for days; it resumes the same session, receives what was answered or cancelled meanwhile, and its runs go on. - A question the client withdraws. Cancelling a question while the link is down
ends the client's wait at once, with a local
cancelledresult, and the next link does not name it, which withdraws the Decision in Chopin before anything else happens. A client that sentinput-cancelon a link that then dropped is treated the same way. - Stop is held, not lost. A Stop Planner, or one run's pause, that cannot
reach the client is held for its return, and Chat says that the workflows will
pause when the machine reconnects; the run cards keep their last reported status
until the client reports otherwise. That covers a Stop pressed while the link is
down and one already sent whose
control-resulthad not come when the link dropped. Astopthe client did not answer within 10 seconds while the link was still attached is kept too: Chat says Chopin has not confirmed it, never that it stopped, and that the workflows will pause when the machine reconnects, and says nothing more when the link is found dead (see Pings). Chat says a Stop stopped Chopin only when the client confirmed it, that it was asked to when its connection is known to be down, and that it was not confirmed otherwise. Afterresumed, before anything else, the server sends the client onestopfor every run (or one per run named), however many times it was pressed, and the client answerscontrol-resultas for anystop. The client may already have applied the original, whose answer was lost, so it can receive astopfor runs that are paused; repeating it changes nothing. Astopthe client answered, whether it applied or refused it, is not sent again. One it never answered stays owed, and aresumethe client did answer undoes it only for the runs thatresumecovers: a Stop of every run stands for the other runs, and a Stop of one run is undone only by aresumeof that run or of every run. A held Stop whose answer is lost because the link dropped once more is sent again with the nextresume. A Resume pressed meanwhile is not held: Chat says the workflows could not be resumed, and it can be pressed again once the client is back. Stop on a Planner that only waits on a question withdraws the question explicitly, and Chat says so; the client learns of it when it resumes. That ends what the link kept, and the account holds the document for the grace period as a detached one does. - How to resume. The same account attaches again, offering
resumable-sessions, and sendsresumeas its first message afterattached:{ "type": "resume", "sessions": [{ "session": "", "requests": [""] }] }, every session it still holds with theinput-requestids it still waits on. The server answersresumedin the same shape: the sessions it kept and gave back, each with the requests it still holds, whether open or answered while the link was down. It then sends each answered request'sinput-resultthe client has not acknowledged, and the client acknowledges it withinput-received. The client sendsrunsagain for the sessions it got back, as after anyattach. A session or request the client does not name has ended and is withdrawn; one the server does not list has ended there: the client settles its questions as cancelled and calls itsdestroyhandler for the session. A client that sends anything else first, or does not offer the feature, holds nothing to resume. Arefusedwithretry(unavailable, see Ownership) does not end the client's questions: Chopin could not decide, so the sameresumeis sent again on the client's backoff, which may or may not succeed. Any otherrefused(planner-attachedincluded, whoever holds the document) and anyerrorends them. - A client that does not offer the feature. It behaves as it always did: when its
link drops, Chopin ends the turn, withdraws the questions the client asked, shows
its live runs as
stoppedand forgets its sessions. Such a client settles its own waiting questions when its socket closes and holds nothing it could resume, so keeping them open would promise answers nobody can receive. A client that wants its work to survive a drop must offerresumable-sessions; the package client does by default (resumable: falseopts out). - What a client implements. Offer
resumable-sessionsinChopin-Agent-Featuresand check thatattachedlists it; acknowledge everyinput-resultwithinput-received, including one for a request already settled; keep each session and its waitinginput-requestids across a drop; on the next connection sendresumefirst and handleresumed, then the redeliveredinput-results andstops, thenruns; retry the sameresumeon arefusedwithretry, and treat any otherrefusedorerroras final. - What still ends it for good. Its own
detach; any definitiverefused(every one withoutretry) or protocol error; the owner's sign-out or its expiry; a Planner reset; revoking or unpairing the machine that attached it; the document's owner choosing another machine for it; archiving or deleting the document; a Stop that withdraws its last question; and the same account attaching with a client that does not resume, which includes a browser sign-in attaching over a paired machine's parked work, since that client cannot resume it. All of these withdraw the questions and show the runs asstopped, and a laterresumefinds nothing. A server restart does not end it: see below. A person with write access can always end a disconnected Planner by resetting it.
A server that is restarting closes every link with 1012. A link that offered
resumable-sessions keeps what it held, as for any close it did not choose: 1012 is not
one of the closes that ends the link for good, and, unlike 1001, it says the server will be
back. A client that also offers restart-notice is told how soon, with restarting just
before the close, and follows this rule:
- On a 1012 close, connect again at once. Do not wait out the backoff: a proxy in front of Chopin holds the upgrade until the new process answers.
- Send
resumeas usual. The new process does not refuse it inside the window it announced: before it has restored the owner's browser sign-in (see Ownership) it holds theresume, keeps the link, and answers when the sign-in is back. It may refuse it withunavailableandretryonce the window is over with the sign-in still not back, or when it could not decide for another passing reason. Retry the sameresumeevery 250 ms untilreconnectWithinMshas passed since the close, then go back to the client's own backoff. - Any other
refused, and anyerror, is final, as for a resume at any other time.
A client without the feature sees only the 1012 close and resumes on its own backoff. If
that is longer than the server's hand-over window, the document shows its Planner as
disconnected once the window ends, as it does for any dropped link. The package client
offers the feature with resumable-sessions and reports reconnectWithinMs on the
LinkClosed its closed promise settles with; it leaves connecting again to its caller, so
the rule is for the backend to follow. The test client in apps/server/src/testing/agent-link.ts
(resumeAfterRestart) implements it.
The server keeps links, sessions and open requests in memory, and writes what a restart
must carry over to storage: when a link drops with work kept, and for every live link at
its cut, one planner_link_holds row per document with each session's waiting request
ids, for each question the decisions it asked and the kind of request it was, the answers the
client has not acknowledged and the Stops it has not heard. The
server tells every link it is going down before it closes any socket, closes each with 1012
(with restarting, whose reconnectWithinMs is the hold's window, for a client that offered
restart-notice), and no link withdraws a question on the way out, so the Decisions the
Planner had asked stay open and answerable in the document.
The next process rebuilds each hold as a suspended one. Until its hand-over window
(HANDOVER_PLANNER_WINDOW_MS, 10 seconds by default, counted from the release of the lease: the
next process starts it as it rebuilds the hold, after the release) ends, the
document's members see the Planner attached and the run cards Chat stored stay as they were; a
@chopin message, comment note or task draft sent before the client resumes is refused saying
it is still connecting, with nothing kept for later.
A returning client attaches, and sends resume as usual:
- Before the owner's browser sign-in is back, which a browser's cookie restores, the
resumewaits inside the window: nothing is refused, the link stays attached, the document shows the machine attached, and the sessions come back the moment the sign-in does. Aresumestill without it when the window is over, or sent after it, is refused withunavailableandretry, and the hold stays. A client that offeredrestart-noticeretries every 250 ms untilreconnectWithinMshas passed, then on its own backoff; there is no deadline for the hold itself. A paired machine's backend has no such wait: its login is made again from the machine's own authorization, and the credential it was issued is accepted at once, so no HTTP 401 follows a restart. - After it, the sessions the client names come back, as after any dropped link, and
resumedlists the requests the server holds. An answer given while the link was down, or sent and never acknowledged, is one: it is delivered once afterresumed. A question the client still waited on is listed inrequests, and the client keeps waiting on it. The new process asks the members of the document the same question again when the document opens: it is the Decision the old process created, still open in the document, and its answer goes to the client's own request, as aninput-resultwith the result the old process would have sent, once. An answer given before the client is back waits and is sent afterresumed; one given after goes straight to it. No turn of its own follows, because the question is waited on. A question a client resumes without, because it no longer waits on it, is withdrawn, asinput-cancelwithdraws it. Each Stop held for the client is sent first. The client'srunsfor a resumed session update nothing Chat shows until a Planner turn starts a session of its own. - An answer its client is never given. When the client that asked does not come back to resume (it attaches with nothing to resume, or resumes without the session), an answer a member had given meanwhile cannot reach its request. It reaches the Planner once, as a turn of its own that names the question, the option chosen and who chose it, started when the Planner is attached (see Decisions). A question of a request that was not kept, one asked while the old process was stopping or after a crash, is not waited on at all, and its answer is handled the same way. Exactly one of the two delivers an answer. What is not carried over is a workflow run's pause: an answer to a question a workflow run asked is sent when it is given, without the wait that keeps a paused run stopped.
- A client that sends anything else first, or resumes with nothing, holds nothing to resume: the kept sessions end and the run cards stop.
When the window ends with no resume, the hold is disconnected, as a dropped link is
today: the document's members are told, its run cards say the Planner is disconnected, and the
hand-over logs a miss naming the cause, the owner's sign-in or the client not back. It stays
for the client's return and nothing times it out. A hold that was already disconnected when the
cut came keeps its state and gets no window, and one in its grace period is not kept. An
explicit end (sign-out, a reset, revoking the machine, archiving the document) removes
the hold and its row. Under AUTH_MODE=local, which is never handed over, holds stay in
memory and a restart ends them as before.
After a crash, only the hold of a link that had already dropped is stored, with no window, so
its client's resume finds it disconnected; the live link's work is lost as it always was. A
crash of the process a cut handed over to leaves the same: it rewrote the row without the window
when it rebuilt the hold, so the process after it finds the hold disconnected and keeps the
sessions for the client's resume.
Neither the link closing nor the shutdown posts a Chat line: no "disconnected" notice
and no "was stopped after" line, since nobody stopped anything and Chopin merely restarted. An answer given to a Decision nobody
waits on any more reaches the Planner as a turn of its own once a Planner is attached
(see Decisions). Nothing is reported as the Planner stopping its work
because of a network drop.
A request is never asked twice: it keeps its request id and its card. The package
client implements resuming and asks for the feature by default: pass
connectAgent({ resumable: false }) for a client that wants everything to end when
its link drops. Its resumption settles, when the link closes, with what to pass the
next connectAgent as resume, or with nothing when the link ended any other way.
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. In the sandbox that
checkout is read-only. 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_PLANNER_TOKEN_FILE |
A file (mode 0600) that always holds a current credential, replaced before it expires. |
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 removes the worktree itself. Whether the backend or the
connector makes it depends on the sandbox:
-
Without a sandbox (none works on the machine, or
--no-sandbox), the folder is not there on the first launch and the command makes the worktree, for example:git -C "$CHOPIN_CHECKOUT" worktree add "$CHOPIN_WORKTREE" -b <branch>
-
In the sandbox the worktree already exists on every launch, the first too: the connector makes it on the host before the backend starts, as a linked worktree of
CHOPIN_CHECKOUTdetached at itsHEAD, and leaves it as it finds it on later launches. The backend must use it, as it must after its first launch anyway:git -C "$CHOPIN_WORKTREE" checkout -b <branch>starts a branch, and it can commit and push from there.git worktree addfails in the sandbox, because the sandbox lets the backend write that one worktree's git directory and not the list of worktrees in the checkout's.CHOPIN_CHECKOUTis read-only: it is the person's own checkout, and the sandbox never lets an agent write its files, its submodules, its.git, its index orHEAD, whether it is a regular checkout or a linked worktree of theirs, and no path its owner adds with--sandbox-writechanges that. A backend reads it, and does its work and its edits inCHOPIN_WORKTREE, from where its changes reach the person as commits and a pushed branch. See Git in the sandbox. A folder atCHOPIN_WORKTREEthat holds something and is no worktree is left as it is.
The connector renews the Planner credential in CHOPIN_PLANNER_TOKEN_FILE 5 minutes
before it expires, for as long as the backend runs and Chopin still lets the machine run
the document, and tries again if it cannot reach Chopin, keeping the file it has. It
deletes the file when the backend exits, when the document is withdrawn, or when Chopin
refuses the machine for good, such as a revoked pairing. Its content, like the
launch's credential, never reaches the connector's output. A backend that outlives a
link, as a resumable-sessions one does, reads it again before reattaching.
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 SHA-256 digest, in planner_credentials,
written before the credential is handed out. A process that starts after a restart
therefore accepts an unexpired credential at once, and a backend that reconnects with it
is not refused with 401 while it waits for the machine's connector to renew it. The rows
go when the credential expires, when the document's Planner is stopped on the machine,
when the machine is unpaired or loses its GitHub authorization, and with the document.
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. That holds for a teammate's document too, when the machine's owner shares it and someone chose it or made it their favorite (or Default machine gave it): the Planner runs as the owner. So do the builds and runs that person starts on the machine, which the connector claims with the same machine token; Chopin records the person as who asked, and the build's agent is told to name them in each pull request (see Share a machine with teammates).
- 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, a backend that did not offerresumable-sessionsexits. Until the credential's 15 minutes have passed it may instead reattach with it after 1001, 1012 or a dropped socket; after that the credential is refused and it must exit. Chopin forgets a credential once it has expired. - A backend that offers
resumable-sessionsdoes not exit when its link drops unexpectedly: it keeps its process, its sessions and its runs, and attaches again withresume, however long the network or the machine is away. Chopin keeps the document's Planner, its run cards and its open Decisions for it, and holds any Stop meanwhile. Because the credential it was launched with is good for 15 minutes, it readsCHOPIN_PLANNER_TOKEN_FILEbefore each attempt and again whenever the upgrade is refused with 401, retrying on its own backoff. The connector replaces the file before its credential expires; a machine that slept through that renewal gets the new file once the connector's timer next fires, and a backend that meets a refused upgrade in between reads it again and retries. When Chopin refuses itsresumewithretry(it could not check the document or the account, as GitHub failing at that moment does), or the upgrade fails with 503, it keeps its sessions and tries again on its backoff, and does not exit. It exits when its owndetachends the link, when Chopin refuses the attach for good (anyrefusedwithoutretry, includingplanner-attached), when the file is gone (the machine was revoked, or Chopin no longer asks it to run the document), or when it holds nothing to resume. This repository ships no first-party backend: the connector provides what a resumable backend needs, the@chopin/agent-linkpackage client is the reference implementation of the protocol, and a third-party backend that follows this contract is what people run. - 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.