Integrating with milk via ACP¶
milk serve --acp runs milk as a long-lived Agent Client
Protocol v2 agent: an editor (or any other
client) spawns it as a subprocess and speaks JSON-RPC 2.0 over its
stdin/stdout. This doc describes exactly what's implemented today —
separately from docs/internal/design/machine-readable-output-design.md,
which is the full design proposal covering the complete planned surface, most
of which isn't built yet. If you're integrating an editor or client against
milk right now, this page — not the design doc — is the contract to code
against. Where the two disagree, this page wins; the design doc's own status
notes track what's landed phase by phase.
Launching it¶
milk serve --acp
- Speaks newline-delimited JSON-RPC 2.0: one JSON object per line, on both stdin (client → agent) and stdout (agent → client).
- stdout carries protocol traffic only. Human-readable warnings (e.g. a stale config recovery notice) go to stderr, never stdout — never try to parse stdout as anything but JSON-RPC lines.
- The process keeps running until stdin closes or it receives
SIGINT/SIGTERM. - One process serves multiple sessions:
session/newcan be called more than once, and each session'ssession/promptcall runs independently — a long-running turn in one session never blocks another session's calls.
What's implemented¶
| Method | Direction | Status |
|---|---|---|
initialize |
client → agent | ✅ real |
session/new |
client → agent | ✅ real: starts a fresh session — or, by default, adopts the cwd's most recent stored session (resume-by-default; see "Session resume, list, and history replay" below) |
session/list |
client → agent | ✅ real: every stored session sorted by last use descending (all cwds, or cwd filter), SessionInfo{sessionId, cwd, title, updatedAt} (title = session name, else the first user turn truncated), opaque base64("o:<n>") offset cursor, 50 per page |
session/resume |
client → agent | ✅ real: attach the stored session (the request cwd must match the session's, else -32602; unknown ID → -32002), idempotent reattach when it is already open in this process; replayFrom — omitted/null resumes without replaying, {"type":"start"} replays all retained history before the response (message upserts, see "Session resume, list, and history replay" below), any other cursor → -32602 before anything is emitted; returns availableCommands |
session/close |
client → agent | ✅ real: cancel any running turn + suppress follow-ups + persist + free (idempotent, the file is kept — close ≠ delete) |
session/delete |
client → agent | ✅ real, behind the session.delete capability ({}): close-if-open, then drop the session file and index entry (accepts an unambiguous ID prefix) |
session/set_config_option |
client → agent | ✅ real: two options, advertised as configOptions in the session/new and session/resume responses — think (boolean, = /think on\|off) and routing (select auto|primary|escalation, = clear pins / /primary / /escalate). The response carries the full updated option set. Unknown configId, a value outside the declared set, or the wrong value type → -32602; unknown session → -32002 |
session/prompt |
client → agent | ✅ real |
session/cancel |
client → agent (notification) | ✅ real |
session/update (state_update) |
agent → client | ✅ real: running at turn start, idle at turn end |
session/update (config_option_update) |
agent → client | ✅ real: pushed when a slash command (/think, /escalate, /primary) moves an option's value, so the client's settings UI never drifts from the commands. Not sent for changes made through session/set_config_option itself (its response already carries the state) |
session/update (available_commands_update) |
agent → client | ✅ real, sent once right after the session/new response. Advertises exactly the commands listed under "Slash commands" below (no per-argument granularity; the list itself is static for the process, while the config it may edit is re-read from disk at session/new — see "The setup wizard" below) |
session/update (agent_message_chunk) |
agent → client | ✅ real: turn output as one chunk per completed turn, not per token (see caveat below), plus occasional out-of-turn notices — the background-follow-up and update-available announcements |
session/update (user_message / agent_message / agent_thought upserts) |
agent → client | ✅ real: chat-history replay on session/resume (and resume-by-default adoption) plus the accept-echo of each user message at turn start (same messageId the session/prompt response returns). Upsert semantics: the same messageId patches rather than duplicates |
session/update (user_message_chunk) |
agent → client | ✅ real: the v1-client fallback shape of the accept-echo and replay (v1 has no message upserts; same messageId appends into one message) |
session/update (tool_call_update) |
agent → client | ✅ real, for both the local-provider and claude-cli-escalation paths |
session/request_permission |
agent → client | ✅ real. Tool approvals are local-provider agents only — see caveat below. The setup wizard also uses it as clickable choice prompts for any provider (see "The setup wizard" below) |
elicitation/create |
agent → client | ✅ real: sent by the setup wizard's form dialogs (form mode, session scope) |
elicitation/complete |
agent → client (notification) | ✅ real: fire-and-forget once the user's answer has been consumed |
Everything else a real ACP client might try — auth/login, auth/logout,
the legacy v1 session/load (client-supplied
history — the old wrong shape; history replay is standardized via
session/resume's replayFrom instead), $/cancel_request as a
standalone per-request cancel — returns the standard JSON-RPC -32601
method not found error. That's a deliberate, documented gap, not a bug: see
Deferred below.
Slash commands¶
A session/prompt whose text starts with a known slash command is executed by
milk itself rather than sent to the model. Output comes back as one
agent_message_chunk (ANSI stripped) between the usual running/idle
state_updates. The advertised list and the executable list come from one
table (cmd/milk/acp_commands.go), so nothing is advertised that doesn't run.
| Command | Effect |
|---|---|
/escalate [fresh] [<msg>] |
pin all turns to the escalation agent; with a message, force that one turn |
/primary [<msg>] |
pin all turns to the primary agent; with a message, force that one turn |
/learn <fact> |
store a persistent memory |
/memory [global\|session\|<pattern>] |
list percepts |
/usage, /metrics |
token usage / recent metrics |
/export [json\|<path>] |
print the transcript, or write it to a file |
/sessions [all] |
list stored sessions for this directory (all: every directory), * marking this conversation's current binding |
/new [name], /clear [name] |
start a fresh (optionally named) session and rebind this conversation to it; the previous binding stays on disk, resumable (see "Session commands and view rebinding") |
/resume <id\|prefix\|name> |
switch this conversation to a stored session — exact id, unambiguous id prefix, or cwd-scoped name; bare /resume lists what is resumable |
/drop [<id\|prefix\|name>] |
delete the current session (the conversation lands on a fresh one), or exactly the referenced one (conversation untouched) |
/skip-permissions [on\|off] |
approve every tool call without asking (seeded from dangerously_skip_permissions, like the TUI) |
/think [on\|off] |
show or hide agent_thought_chunk updates for this session (default on, as before, unless show_reasoning is set to false) |
/config |
print the merged config as fenced JSON |
/config show |
the same, annotated per field [global]/[local]/[default] |
/config open |
open the config file: launches the platform file opener (xdg-open / open / start) detached on the machine milk runs on and reports exactly what happened — an ACP agent is headless and never launches an interactive editor itself; when no opener is available it says so and falls back to the config path(s) for the client's editor |
/config init, /init |
run the interactive setup wizard (see below) |
/agent [list] |
list configured agents (switching is TUI-only) |
/update check\|status\|install\|skip |
check for milk updates, install one, or skip a release (honored by every later check). install replaces the binary under the running process, so the reply says to restart the milk agent — and on Windows, where the running binary cannot be replaced, it reports the saved download path instead of claiming success |
/setup telegram [on\|off\|status] |
configure Telegram remote oversight (see "Remote oversight (Telegram)"). The bare form runs the interactive wizard: the bot token is typed in chat and consumed by the wizard (never sent to the model; cancel aborts), then a clickable confirm — or typed done as the floor. on/off flip the backend without touching stored credentials, status reports the current state |
/tasks, /task done <id> |
list / complete tasks (session and global) |
/bg [list\|show <id>\|start <task>\|stop <id>] |
list, inspect, start or stop background agents |
/workflow <name> <task> [--<role> <agent>] |
run a workflow (see "Workflows, tasks and background agents") |
/workflow resume\|status\|clear |
continue, inspect or clear the session's saved workflow |
/help |
list the above |
TUI-only commands (/panel, /colorize, /paste, /attach, /mcp,
/reload, /workflow reconfigure,
/task add, …) are not advertised. If sent anyway they get a "only available in the
milk TUI" reply instead of reaching the model. /list is executable but no
longer advertised — the deprecated alias of /sessions, kept one release with
a rename hint. Text that merely mentions a
command mid-sentence is an ordinary prompt. Routing pins from /escalate and
/primary are per ACP session. /think and the two pins are also exposed as
session config options (think, routing) — see session/set_config_option
above; the commands and the options are two views of the same state.
The setup wizard¶
/config init (or its /init alias) runs the same guided first-run wizard
the TUI runs, driven entirely over ACP — the whole point being that someone
who only ever meets milk through their editor can configure it:
- Form dialogs where the client supports them. When the client advertises
form-mode elicitation (
clientCapabilities.elicitation.formatinitialize), the wizard asks its steps aselicitation/createform dialogs inside the/config initturn — one per step, that step's prompt as the dialog message. Choices (provider, the escalation question) come back as titled single-selects and the agent-tools question as a multi-select, and every step's default is pre-populated in the form — and spelled out in the field description (type 'default' to accept …) for clients that pre-populate nothing. - Clickable choice prompts everywhere else. Every non-secret step with
something worth clicking is also offered as
session/request_permissionoptions — the one prompt surface every ACP client renders as buttons (it is how tool approvals work): one button per choice or example, ause default — …button standing in for the empty turn ACP chat can't send,type my own value…handing that step to typed input, andcancel setup. A client whose permission responses carry no recognizable outcome is detected once and never asked again (its steps go to typed input). - One prompt per answer as the floor. Questions the client can't render
as dialogs or buttons come back as the command's
agent_message_chunk; every followingsession/promptis consumed as that step's answer (validation errors re-prompt the same step, exactly as in the TUI) until the wizard finishes with the config written and a completion summary. While the wizard is pending, answers are not sent to the model. Since an empty turn can't be sent, every question states what the worddefault(or-) means for that step, and it applies like pressing Enter in the TUI. - The credential step is typed, never form-rendered. Elicitation form
mode must not carry secrets (the spec forbids it — API keys included), so
the API-key question is skipped with one click (
skip — no credential, which routes to the token-command step) or typed in chat (still never forwarded to the model); the remaining steps return to dialogs. A dismissed dialog or a failed round trip likewise hands the current step to typed input with every applied answer kept. - Escape hatches (the TUI cancels with esc, which ACP doesn't have): a
plain
cancel/quit/abortanswer aborts the wizard, and any recognized slash command cancels it first, then runs — so/helpmid-wizard works. Re-sending/config initrestarts it. - No restart needed. When the wizard commits, the session rebuilds its
primary/escalation runners from the freshly written config, so the very next
prompt uses the agent that was just configured. Sessions created afterwards
get the new config too:
session/newre-reads the config from disk rather than the server's startup snapshot. - No editor question. The TUI's trailing "open config in editor now?" is
skipped —
/config openanswers with the path for the client's editor.
Workflows, tasks and background agents¶
These work over ACP without the TUI's panels; their state reaches the client as session updates instead.
- Tasks. The model gets the task tools (
create_task, …) and/tasks,/task done <id>work. Every change to the session's task list sends a plan update carrying the whole list (pending/in_progress/completed;blockedhas no ACP status and is sent aspendingwith a "(blocked)" suffix and_meta.blocked). - Background agents. The model's
spawn_background_agenttool and/bg start|list|show|stopwork. Each job is atool_call_updaterow (background_agent, in_progress → completed/failed; the finished row carries the result inrawOutput, and/bg show <id>prints it in full). Jobs outlive the turn that started them, andsession/canceldoes not stop them (/bg stopdoes). milk follows up on its own: when a wave of agent-spawned jobs finishes (or a/bg startjob does) and no turn is running, it announces itself with a[milk] background agents finished …message chunk (the client never sees the synthetic prompt, so without it the turn would start with no visible cause) and starts a turn without a prompt —state_updaterunning, the agent's report of the results as ordinary message chunks, thenidle— as the TUI does. ACP v2 permits this ("background activity MAY … emit updates while the Agent is idle"); a v1 client has no such notion and may not expect it. If a turn is already running when the jobs finish, the follow-up runs right after it. A client prompt that arrives during a follow-up waits for it to finish (it isn't cancelled, which would lose the results it already consumed), so turns never overlap.session/canceldoes abort a follow-up. - Workflows. The model's
start_workflowtool and/workflow <name> <task> [--<role> <agent>]run the native workflow engine inside thesession/promptthat started it, so the prompt stays open for the whole run andsession/cancelcancels it (/workflow resumecontinues from the last checkpoint). Roles without an explicit agent use the escalation agent. Progress is a plan update with one entry per stage, plus aworkflowtool-call row and start/finish messages. Stage output streams into that row (see "Live output" below), under a── <role> ──header per stage; the workflow definition still decides what it writes to disk. User questions are auto-answered: a step that would ask the user something (designer questions,user_checkpoint) is shown to the client and answered with "continue" (accept the designer's defaults), because the user cannot reply while the prompt is open. Put specifics in the task text to steer it. Only one workflow runs per session at a time. - Live output. A background job's output and a workflow's stage output
stream into their tool-call rows (
job:<id>,workflow:<n>), flushed before the row'scompleted/failedupdate. v2 clients gettool_call_content_chunkappends; v1 has no append, so v1 clients get atool_call_updatewith the full content so far, at most every 400 ms. Output is capped by the live buffer, so a very long run shows its tail. - Plan shape depends on the client. A client that sent
protocolVersion: 1ininitializegets the v1planupdate (one plan per session, noplanId; tasks and workflow entries are concatenated, andcancelledis sent ascompletedwith a "(cancelled)" suffix). Anything else gets v2plan_updatewith aplanIdper plan (tasks-<session>,workflow-<n>).
Tool permissions¶
A local-provider agent asks before running side-effecting tools (file writes,
shell commands, …) with session/request_permission, unless the tool is
already granted, matches bash_allowed_patterns, or skip-permissions is on.
- Request shape. Title
Allow <tool>?, the call summary as description, and the pending tool call as subject: v2 clients getsubject: {type: "tool_call", toolCall: {toolCallId}}; a v1 client (one that sentprotocolVersion: 1) gets v1'stoolCallobject instead. Options areallow_once/reject_once. An approval is remembered for that tool, as in the TUI. - A client that can't answer. If
session/request_permissionfails (for instance a client that doesn't implement it), the tool is denied and milk says so in a message, naming the cause, rather than only reporting "denied by user". Use/skip-permissions on(or setdangerously_skip_permissionsin the config) to run tools without asking. - Background agents ask too. Their prompts point at the job's own
job:<id>tool-call row and say which job is asking; the wait is bounded by the background-agent timeout, after which the tool is denied./skip-permissionsanddangerously_skip_permissionsapply to jobs as well. (In the TUI the same job ask exists: the TUI prompt raced with remote oversight and the timed answer, bounded by the job timeout.) - Not covered yet: the claude-cli escalation agent (its tool permissions are denied by default over ACP, and no request is ever sent), and agents that a workflow role or a tool-agent entry builds separately from the session's primary/escalation agents.
- Remote oversight races along. When
remote_oversightis configured, the backend's allow/deny (or itstimeout_actionon timeout) is asked in parallel with the client and the first answer wins — the TUI's rule. The remote side is serialized process-wide: the Telegram backend has a single prompt slot, so concurrent sessions queue their remote asks instead of stealing each other's.
Remote oversight (Telegram)¶
milk serve --acp runs the same remote oversight as the TUI
(docs/guides/operations.md's "Remote oversight (Telegram)") — one notifier per
serve process, built at serve start from remote_oversight in the config
and rebuilt when /setup telegram changes it:
- Turn and tool notifications — turn start/response/done, slash-command
output, and (gated by
notify_tools) tool use/results forward to the backend for both providers, plus workflow turns (workflow:<role>). - Permission prompts race the client —
session/request_permissiongoes to the editor as always; the first real answer wins between the client and the remote surface (see the Tool permissions section above). Background asks (job/workflow tool prompts, doom-loop confirmations in unattended contexts) add a third source, the timed answer: at the configured deadline (default 360s) the configured default applies — see docs/guides/operations.md's "Background asks (timed answer)" and/permissions. - Bot messages run as turns — a message you send the bot runs in the
live session with the most recent activity, echoed to the client as an
ordinary user message; it queues while a turn is running and runs at turn
end. Messages that arrive before any session exists are held and run on
the next
session/new.
Routing and warnings¶
- Routing. Each model turn sends a
_milk/routenotification (agent,target,reason) once the router has decided, and the turn's closingidlestate_updatecarries the same as_meta["milk/route"]. A one-line message ("→ now handled by( ): ") appears only when the handling target changes from the previous model turn. - Loop and consumption warnings. milk's loop detector runs per session:
streamed reasoning is checked for repetition as it arrives (even with
/think off), and each finished turn is checked for token velocity, silent burn, turn flood and cross-turn repetition. A verdict is sent as a[⚠ loop detected: …]or[⚠ consumption: …]message chunk and as a_milk/warningnotification (category,consumption,message). Withloop_detection.auto_interrupt: truethe running turn is cancelled too. Workflow steps are exempt from the reasoning check, as in the TUI. - Custom methods. ACP requires custom notification methods to start with
_, and clients should ignore ones they don't know. milk lists the ones it emits ininitialize'scapabilities._meta.milk.notifications.
Caveat: agent_message_chunk is not token-level streaming¶
ACP's naming suggests incremental delivery, but milk's underlying callback
(OnResponseSegment) only fires once per tool-call boundary and once more
for the final text — for the common case of a text-only turn with no tool
calls, that's exactly one agent_message_chunk carrying the whole
response, not a token stream. A client expecting live token-by-token
rendering will instead see the full text arrive in one notification right
before the idle state_update. Real partial-token streaming would need a
new, lower-level hook in internal/agent/local/internal/agent/claude that
doesn't exist yet.
Outside of turn output, agent_message_chunk is also milk's channel for the
occasional one-line notices that fire while the agent is idle: why a
background-follow-up turn is starting, the one-shot "new release
available" announcement (debounced across sessions — at most one per session,
never for a release chosen via /update skip), and the resumed session …
notice that resume-by-default adoption sends (see "Session resume, list, and
history replay" below).
Caveat: permission prompts only cover local-provider agents¶
If the session's active agent (primary or, after routing, escalation) is a
local OpenAI-compatible/Bedrock provider, a tool call needing confirmation
sends a real session/request_permission request and blocks for your
response. If the active agent is claude-cli (milk's default escalation
agent), no permission request is ever sent — every tool call it attempts
is silently denied by claude's own default behavior, visible only in its
response text. claude-cli speaks its own separate control-request wire
protocol for permissions; bridging that through ACP is unbuilt. If you need
an escalation agent that can actually use tools under ACP today, configure a
local-provider agent as escalation_agent instead of the claude-cli default
(see docs/guides/providers.md).
Capability negotiation note¶
initialize's response advertises capabilities.session: {"delete": {}}.
Per the upstream ACP schema, supplying any value for session (even {})
declares support for the entire baseline — session/new, session/list,
session/resume, session/close, session/prompt, session/cancel, and
session/update — as one bundle; there's no finer-grained flag for a subset.
milk now implements the whole bundle, so the advertisement is simply
accurate; the one add-on flag is delete ({}), which additionally
advertises session/delete.
Example exchange¶
A single-turn conversation, request/response pairs annotated (actual traffic is newline-delimited, one JSON object per line — formatted here for readability):
// → client sends
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":2,"info":{"name":"my-editor"}}}
// ← agent responds
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":2,"info":{"name":"milk","version":"0.9.0"},"capabilities":{"session":{"delete":{}}}}}
// → client sends
{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/home/me/myproject"}}
// ← agent responds
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"a1b2c3d4-..."}}
// → client sends
{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"a1b2c3d4-...","prompt":[{"type":"text","text":"what does go.mod declare as the module name?"}]}}
// ← agent sends notifications WHILE the request above is still pending —
// the response to id 3 only arrives after the final one of these:
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"a1b2c3d4-...","update":{"sessionUpdate":"state_update","state":"running"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"a1b2c3d4-...","update":{"sessionUpdate":"tool_call_update","toolCallId":"toolu_1","name":"read_file","kind":"read","status":"in_progress","rawInput":{"path":"go.mod"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"a1b2c3d4-...","update":{"sessionUpdate":"tool_call_update","toolCallId":"toolu_1","name":"read_file","kind":"read","status":"completed","rawOutput":"module example.com/t\n\ngo 1.26\n"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"a1b2c3d4-...","update":{"sessionUpdate":"agent_message_chunk","messageId":"msg-1","content":{"type":"text","text":"go.mod declares module example.com/t."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"a1b2c3d4-...","update":{"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn"}}}
// ← agent responds (only now — session/prompt was held open for the whole turn)
{"jsonrpc":"2.0","id":3,"result":{"messageId":"msg-1"}}
Continuing an earlier conversation (see "Session resume, list, and history replay" below for the full semantics):
// → client asks what conversations exist for this workspace
{"jsonrpc":"2.0","id":4,"method":"session/list","params":{"cwd":"/home/me/myproject"}}
// ← agent responds (last-use first; title falls back to the first user turn)
{"jsonrpc":"2.0","id":4,"result":{"sessions":[{"sessionId":"d4c3b2a1-...","cwd":"/home/me/myproject","title":"rename the config flag","updatedAt":"2026-09-28T09:30:00Z"}]}}
// → client continues that conversation
{"jsonrpc":"2.0","id":5,"method":"session/resume","params":{"sessionId":"d4c3b2a1-...","cwd":"/home/me/myproject"}}
// ← agent responds (commands come inline; an update-available notice, if
// any, follows as a session/update)
{"jsonrpc":"2.0","id":5,"result":{"availableCommands":[{"name":"/export","description":"print the session transcript (or another session's), or write it to a file","input":{"type":"text","hint":"[json|<path>|session <id>]"}}]}}
Trying an unimplemented method (the legacy v1 history-load — deliberately not implemented, see "Known gaps"):
// → client sends
{"jsonrpc":"2.0","id":6,"method":"session/load","params":{"sessionId":"d4c3b2a1-...","history":[]}}
// ← agent responds
{"jsonrpc":"2.0","id":6,"error":{"code":-32601,"message":"method not found: session/load"}}
Session resume, list, and history replay¶
session/list, session/resume, session/close and session/delete are the
ACP v2 standard session-management surface; this section is their full
semantics, including milk's one deliberate extension: resume-by-default on
session/new.
Resume-by-default: session/new adopts¶
milk's session model is one-conversation-per-cwd — launching the TUI in a
terminal continues that directory's conversation unless asked not to
(--continue is the default, --new opts out). Over ACP, session/new is
the entry point clients actually call, so it gets the same default: it
adopts the cwd's most-recent stored session (equivalent to resuming that
session) iff all five conditions hold:
- config
acp_resumeis on (default: on;falserestores strict-spec behavior —session/newalways starts empty), - the request's
_meta.milk.freshis nottrue(the per-request escape hatch —_metais ACP's sanctioned channel for exactly this), - this serve process has no live session whose cwd is the request's
cwd(the secondsession/newfor a workspace is therefore always fresh — "new thread" keeps working inside a running client), - the candidate session was not closed earlier in this process (an explicitly closed thread is never silently resurrected), and
- the store holds at least one session for the cwd.
Anything else behaves exactly as before. On adopt, the response returns the
adopted session's id — the id the client keeps using for this
conversation — and adoption never hides state: the bounded history replay
below is mandatory (the client's panel is empty), and a one-line notice
precedes it (resumed session … — N earlier turns; /export prints the full
transcript). A spec-strict client that expects session/new to be empty
therefore sees old context at most once per workspace open — announced and
replayed, never silently. TUI parity: --continue ≈ adopt; --new ≈
acp_resume: false or _meta.milk.fresh: true.
History replay (replayFrom)¶
- omitted or
null→ resume without replaying anything. {"type":"start"}→ replay all retained history before the response, assession/updatemessage upserts (user_message/agent_message/agent_thought) plustool_call_updaterows for tool trails (eachrawOutputpaired from the recorded tool result). A v1 client gets the chunk-form fallback instead (user_message_chunketc. — samemessageIdappends into one message).- any other cursor type (including future
_-prefixed extensions) →-32602, rejected before anything is emitted — milk refuses rather than guessing where to replay from.
Message identity. Replayed messages carry deterministic ids derived from
their history index (hist-u3, hist-a5, hist-t5-0, …), so replaying twice
patches the client's existing messages instead of duplicating them. Live ids
are suffixed with a per-process run id, so they can never collide with
hist-* or with ids minted before a restart. After a conversation view
rebinds to another store session (see "Session commands and view rebinding"
below) the replay ids are session-qualified (hist-<sess8>-u3), so a new
binding's replay can never patch a message left by a previous binding's; the
view's first binding keeps the plain form (and returns to it when the view
switches back), so already-replayed panes keep patching. One documented
deviation: milk does not retain the original ACP messageIds in its session
file ("Agents … are not required to retain" them), so replayed messages carry
synthesized stable ids.
Bounds. "All retained history" is windowed to keep the notification
blast finite: each message's text is capped (~8 KB head+tail), and the replay
covers the first 10 and last 200 turns, with one explicit marker message in
the gap — [… N earlier turns omitted from replay — /export prints the full
transcript …].
Exploring after resume: /export session <id|prefix>¶
/export prints the current session's transcript — including pre-resume
turns on a resumed session. To preview another session without attaching
it, /export session <id|prefix> dumps that session (exact id or unambiguous
prefix; ambiguity and misses are errors, never a guess), composable with
json or a file path: /export [session <id|prefix>] [json|<path>]. The
exploration flow is /sessions → /export session a1b2 → session/resume.
close vs delete¶
session/close cancels a running turn, drops pending follow-ups, persists
the session file and frees the in-process resources (idempotent; the file is
kept — close ≠ delete). session/delete closes an open session first, then
removes its file and index entry. Either way the id joins this process's
closed-set: the next session/new will not resurrect it (condition 4 above).
Session commands and view rebinding (ADR-0051)¶
/sessions, /new, /clear, /resume and /drop (and the deprecated
/list alias) manage sessions from inside the chat. A chat-typed switch
cannot change the handle the client holds — session/resume's response
carries no sessionId — so an ACP session is a conversation view: the
wire handle is fixed at creation, and the view's current binding (the
store session it talks to) can move. Full rationale:
ADR-0051.
- Handle invariant. A view's handle is the store session it was created
with; a store session is bound by at most one live view.
/resumeonto a session another conversation holds is refused ("open in another conversation — close it there first") — never split-written from two panes. - Rebind, don't respawn.
/new,/resumeand/dropswap the binding in place: memory store, task store and the agents' session context follow the binding; view/user state survives (thethink/routingconfig options, routing pins,skip-permissions, client capabilities). - Switching never hides state. A switch replays the new binding's retained history (the same bounded replay as adoption) with a one-line notice naming it; a fresh binding replays nothing and says so.
session/resume(X)resolution. (1) X is a live view's current binding → that view answers, and X becomes an alias handle for it (dispatch,session/cancelandsession/closeaccept every alias); (2) a live view has handle X → it rebinds back to X ("the conversation that started at X") and answers; (3) otherwise a new view opens on X.session/close(H)closes the view behind H and its current binding — every handle of the view joins the closed-set, as does the binding.session/delete(X)where X is only the current binding of another conversation: X is dropped and that conversation is rebound to a fresh session, told with a notice (no pane keeps showing a deleted conversation)./dropover ACP behaves the same way, and its dropped id joins the closed-set.
Error handling¶
-32601(JSON-RPC reserved "method not found"): the method isn't implemented at all (see the gap list below).-32602("invalid params"): a well-formed request whose params fail validation — e.g.session/resumewith acwdthat doesn't match the session's, or an unknownreplayFromcursor type.-32002("resource not found"): the requested session doesn't exist (session/resume,session/delete).-32000(generic server error): every other failure (a turn-level error, an internal store failure) — don't pattern-match on its text, just surface it.session/cancelis a notification (noid, no response). Sending one for a session with no turn currently running is a safe no-op.- A malformed JSON-RPC line on stdin is silently dropped, not reported — there's no response channel for a line that failed to parse into even a method name.
Known gaps vs. the full design (deferred, not silently missing)¶
Tracked explicitly in docs/internal/design/machine-readable-output-design.md's Phase 2
status note, repeated here for integrators who don't want to read the whole
design doc:
auth/login,auth/logout- the legacy v1
session/load(client-supplied history): the old wrong shape — history lives client-side there. Stays-32601by design; usesession/resumewithreplayFromfor history replay. agentandmodelconfig options: the transport vocabulary exists (acp.AgentOption,acp.ModelOption) but they are deliberately not advertised —/agent switchand/modelhave no ACP command today (/agentis list-only), so there would be nothing for a setter to call.routingonly reflects explicit pins: the router's own auto-sticky escalation reads asauto, and a pin is not persisted across a process restart (session/resumeof a stored session starts atauto). Aroutingchange that arrives while a turn is running is queued and applied when that turn ends (the response already reports the requested value)- the tool-facing
events.Host.Elicitseam (cmd/milk/host_acp.go) — still a stub returning a cancelled result; the setup wizard's form dialogs use the form-capableacp.ACPHost.Elicitround trip instead terminal_update(PTY/agent-owned terminal streaming)- The
_milk/notificationand_milk/memoryextension channels — never emitted (_milk/routeand_milk/warningare; see "Routing and warnings") NewSessionRequest.mcpServers— accepted and parsed, never merged with the agent's own configured MCP serverscwdisn't validated as an absolute path (the ACP spec says it should be), only checked for non-empty
Shared with the TUI¶
ACP and the TUI run the same turn loop (runPrimary / runEscalation) and
the same workflow engine. They also share host-independent cores, so a rule
changed in one shows up in the other: routing (turn_routing.go —
pins, single-turn /escalate and /primary, availability fallback,
auto-sticky escalation after the router first escalates, turn metrics),
workflow launch/resume/clear (workflow_core.go), when a background
follow-up turn runs (followup_core.go), the setup wizard
(initwizard_core.go — step machine, validation, field descriptors, config
commit; acp_initwizard.go — the elicitation form drive) and the
config display (configview.go — /config and /config show). Each
host still does its own channel work: wiring agents to its output, deciding
whether a turn is running, and rendering progress.
For implementers extending this¶
internal/transport/acp— wire shapes (lifecycle.gofor the inbound request/response structs,acp.go/host.go/map.go/plan.go/terminal.go/commands.go/ext.gofor the rest of the ACP v2 vocabulary, most of it already defined but unwired) and the JSON-RPC transport (stdio.go'sStdioConn— generic, no milk-specific knowledge, testable viaio.Pipe).cmd/milk/serve.go— theserve --acpcobra command.cmd/milk/acp_server.go— method dispatch (initialize/session/new/session/prompt/session/cancel); add a newcasehere plus the matching struct inlifecycle.goto wire up another method.cmd/milk/acp_session.go— per-session state and the actual turn dispatch, structurally mirroringcmd/milk/repl.go'sbuildTUIAgents(TUI callbacks →tea.Msg; this file's callbacks →conn.Notify/ACPMappercalls).cmd/milk/host_acp.go— theevents.Hostadapter bridging milk's host-agnostic permission/elicitation interface onto ACP's wire shapes, for local-provider agents.- Tests:
internal/transport/acp/stdio_test.go(transport-layer, race-clean, no subprocess),cmd/milk/acp_server_test.go(direct handler tests, fakeConn, no subprocess),cmd/milk/serve_acp_e2e_test.go(real compiled binary, real subprocess — the closest thing to a live integration test available without a real ACP client in this environment).