ACP Agent Channel¶
ACPChannel connects a RoomKit room to an external coding agent through the
Agent Client Protocol. RoomKit acts as the
ACP client; Claude Agent, Codex CLI, Gemini CLI, or another compatible coding
agent acts as the ACP server.
This is distinct from a future integration where an agent built with RoomKit would itself be exposed as an ACP server.
Installation¶
Install RoomKit with the ACP extra:
The extra currently targets the official Python SDK
agent-client-protocol>=0.11.0,<0.13 and the stable ACP wire protocol v1. ACP
artifact/package versions and wire protocol versions are separate; RoomKit
validates the negotiated protocolVersion.
Basic setup¶
Choose an agent from the official ACP agent registry and use the launch command documented by that agent or adapter:
from pathlib import Path
from roomkit import ACPChannel, ChannelCategory, RoomKit
kit = RoomKit()
agent = ACPChannel(
"coding-agent",
# Replace this argument vector with the selected agent's ACP command.
# RoomKit launches it directly, without a shell.
command=["my-acp-agent", "--stdio"],
cwd=Path("/srv/workspaces/my-project"),
)
kit.register_channel(agent)
await kit.create_room(room_id="coding-session")
await kit.attach_channel(
"coding-session",
"coding-agent",
category=ChannelCategory.INTELLIGENCE,
)
cwd and every additional_directories entry must be absolute paths. A single
agent process is started lazily for the channel. Each Room receives a separate
ACP session, and prompts are serialized within that session. Different Rooms
can make progress concurrently through the same connection.
Environment passthrough¶
The ACP SDK spawns the agent with a deliberately trimmed environment —
HOME, LOGNAME, PATH, SHELL, TERM, USER only (the MCP practice) —
plus whatever you pass in env=. That default silently breaks tooling a
coding agent relies on: without SSH_AUTH_SOCK, for instance, every
git-over-SSH operation the agent runs falls back to the on-disk key files
and prompts for their passphrases on the controlling terminal, stealing
keystrokes from whatever else is reading it.
inherit_env= names parent-process variables to forward. Values are read at
each process spawn (a reconnect picks up a rotated agent socket), unset
names are skipped, and explicit env= entries win over inherited ones.
Nothing is forwarded by default:
agent = ACPChannel(
"coding-agent",
command=["my-acp-agent", "--stdio"],
cwd=Path("/srv/workspaces/my-project"),
env={"MAX_THINKING_TOKENS": "1024"}, # explicit values
inherit_env=["SSH_AUTH_SOCK", "LANG"], # forwarded from the parent
)
Keep the list minimal — the trimmed default exists so the agent does not inherit secrets it has no business seeing.
Reaching an agent you cannot spawn¶
command= spawns the agent here and speaks over its stdio. When the agent runs
somewhere this process cannot start it — on a user's own machine behind a
relay, in another container — pass a transport= instead. ACP does not care
what carries it: the SDK builds its connection from a reader/writer pair, so a
transport is whatever produces one.
import acp
from roomkit import ACPChannel, ACPTransport
class MyRelayTransport(ACPTransport):
@property
def name(self) -> str:
return "my-relay" # shows up in channel.info
async def open(self, client, *, queue):
reader, writer = await connect_to_my_relay()
kwargs = {} if queue is None else {"queue": queue}
return acp.connect_to_agent(client, writer, reader, **kwargs)
async def close(self) -> None:
... # must not raise
def is_alive(self) -> bool:
return not self._socket.closed # optional; defaults to True
agent = ACPChannel(
"coding-agent",
transport=MyRelayTransport(),
cwd="/srv/workspaces/my-project", # a path on the AGENT's machine
)
queue is the SDK's notification queue, which the channel joins before it
closes a turn. SDK 0.12.1 removed it and rejects the keyword, so under that
release the channel passes None and the transport forwards nothing.
command and transport are mutually exclusive, and one is required. env and
inherit_env configure the subprocess spawn, so they are refused next to a
transport rather than quietly ignored — a custom transport carries its own
environment to wherever the agent lives.
Only the pipe is yours. initialize, protocol-version negotiation,
authenticate, per-Room sessions, prompts, cancellation, permissions, config
options and the whole event mapping stay on the channel, so a transport inherits
them without reimplementing anything. Note the split on the session fields:
cwd, additional_directories and mcp_servers are declared to
session/new, which means they name paths and servers on the agent's
machine — the absolute-path check still applies, but the path does not have to
exist here.
session/new also names the room in its _meta (field_meta in Python):
roomkit.live/roomId, and roomkit.live/sessionScope, "room" for the
room's session or "turn" for a standalone turn's.
A transport, or the relay behind it, that keeps one remote session per room
must file a "turn" session under a key of its own and close only that one;
filed under the room, the turn would answer from the room's session and then
close it. The key is absent before 0.91.1. A relay that still answers a turn's
session/new with a session already open makes the turn fail with a
RuntimeError, and the room's session stays as it was.
is_alive() is what lets the channel notice a dead connection: when it turns
false, the next prompt reconnects and drops every session behind the old one (a
reconnect never resumes them). Answer only when you know — the default True
costs at worst a failed request the channel already reports, while a wrong
False throws away live sessions.
The text the agent reads for an event¶
A relay that builds the agent's prompt on its side reads events as the channel
does with acp_event_text(event) (from roomkit): a RichContent is read as
its plain_text, where extract_event_text keeps the markup body.
Recovering a refused room prompt¶
A custom transport's connection may raise
ACPSessionInvalidatedError(reason, recovery_authorized=True) from prompt()
only after proving the session is unusable and no part of the prompt ran.
Authorization belongs to the host: reserve any durable retry identity before
raising, and keep the refusal's receipt. An ordinary error or the default
recovery_authorized=False authorizes nothing. This is a local Python contract,
not a new ACP wire error or a general retry policy.
RoomKit keeps the room's turn lock, closes the failed stream, forgets that
session and its catch-up cursor, then opens a session normally on the same
connection. The same event id, contributed blocks and request are prompted once
more, with the visible room tail recomposed under room_history. Opening uses
the usual cwd, MCP servers and transport-provided instructions/configuration;
RoomKit does not restore private agent state or copy obsolete session options.
A successful recovery reports one response without a stale interrupted mark.
Any observed update (including a plan, usage or thinking) or permission request makes the refusal terminal. So do a second refusal, an opening failure and a standalone turn. Cancellation/abandonment during recovery discards the replacement session and releases the lock. Existing transports do not opt in implicitly. The host must enforce its retry budget across redelivery, restarts and workers; RoomKit limits only this one stream to two attempts.
Runnable without a remote agent: examples/acp_session_recovery.py.
Session config: model, mode, effort¶
ACP agents advertise their tunables as session config options — a list of
select and boolean entries, each with an id (model, mode, effort, …), a
current value and its available choices. RoomKit records them when a session
opens and follows the agent's config_option_update notifications:
agent.session_config(room_id)
# {"mode": "auto", "model": "sonnet", "effort": "xhigh"}
agent.config_options(room_id) # full descriptors, for a picker
# [{"id": "model", "name": "Model", "currentValue": "sonnet",
# "options": [{"value": "opus[1m]", "name": "Opus"}, ...]}, ...]
await agent.set_config_option(room_id, "model", "opus[1m]")
# {"mode": "auto", "model": "opus[1m]", "effort": "xhigh"}
Both readers are empty until the room's session exists — sessions open on the
first prompt. set_config_option() opens one if needed (which starts the
agent process), and returns the mapping the agent reports back, so you see the
value it actually landed on: agents resolve aliases, and a change can
invalidate a sibling option.
Every change — the agent's own, or one you make — publishes an ephemeral
CUSTOM event so UI surfaces can follow along:
{
"type": "acp_config_options",
"session_id": "sess-1",
"values": {"model": "opus[1m]"}, # ids, for logic
"labels": {"model": "Opus"}, # display names, for a status bar
"config_options": [...], # full descriptors
}
A slash command inside the agent is invisible here
Verified against claude-agent-acp 0.61.0: typing /model sonnet at a
RoomKit prompt works — the Claude SDK handles the command locally,
without invoking the model — but the adapter relays only its text output
and sends no config update. The switch is real and RoomKit never
hears about it, so session_config() and anything built on it go stale.
Drive switches through set_config_option() when the value must stay
observable. The Claude Code example intercepts /model in its
content_factory and does exactly that.
Setting up each room's session¶
What a session is opened with is set once, on the channel, and applied to
every session it opens for a room: the room's first, one rebuilt after a
recovery, one opened after the agent
process restarted, a standalone turn's,
and the child room's of a task started with kit.start_task().
from acp.schema import EnvVariable, McpServerStdio
def notes_server(room_id: str) -> list[McpServerStdio]:
return [
McpServerStdio(
name="notes",
command="python",
args=["notes_server.py"],
env=[EnvVariable(name="NOTES_ROOM", value=room_id)], # the server knows its room
)
]
agent = ACPChannel(
"claude-code",
command=["npx", "--no-install", "@agentclientprotocol/claude-agent-acp@0.61.0"],
cwd=Path("/srv/workspace"),
mcp_servers=notes_server, # or a list, the same for every room
session_config={"mode": "default"}, # set on every session before its first prompt
external_tool_handler=PolicyExternalToolHandler(
policy=ToolPolicy(allow=["mcp__notes__*", "add_note", "list_notes"]),
),
)
mcp_serversis a list given to every room's sessions, or a function of the room id (MCPServersForRoom), sync or async, called each time a session opens for that room. A server that acts for a room (starts a task, writes a note) learns which one from what the function puts in itsenv, its URL or its headers.session_configmaps ACP config ids to values (mode,model, an agent's own options). The channel sets each one the new session does not already announce, before the first prompt.- A value you set with
set_config_option(room_id, ...)belongs to the room. The room's next session is set to it too, oversession_config, so a restarted agent comes back on the model and the mode the room had.close_session(room_id)forgets it: the room's next session opens onsession_configalone. - A value the agent refuses fails closed. The session is released and never
prompted, and the turn fails (or
set_config_optionraises) with aRuntimeErrornaming the option. The mode is what makes the agent ask RoomKit before it acts: a session left in another mode would not be a degraded session, it would be an ungated one. When the refused value is one you set on the room (a model a restarted agent no longer offers), set another: aset_config_optionthat opens the room's session sets its value in place of the room's earlier one.close_session(room_id)forgets them all.
examples/acp_room_mcp.py runs Claude Code in two rooms, each with its own
notes server, every MCP call asked of the policy.
Claude Code with the CLI channel¶
The repository includes a complete interactive example that connects a
CLIChannel to Claude Code through ACPChannel:
uv sync --extra acp --extra console
# Authenticate once with a Claude subscription.
npx -y @agentclientprotocol/claude-agent-acp@0.61.0 \
--cli auth login --claudeai
uv run python examples/acp_claude_code.py \
--workspace /path/to/project \
--thinking-tokens 1024
The adapter command and version above match the current
official ACP registry:
@agentclientprotocol/claude-agent-acp@0.61.0, with no additional ACP argument.
It requires Node.js 22 or newer. ANTHROPIC_API_KEY may be used instead of the
subscription login; the example forwards it to the restricted ACP subprocess
environment.
The example enables CLIChannel(markdown=True), which uses Rich's live display
to rebuild the accumulated Markdown document on every text delta. Set
CONSOLE=1 to upgrade to the branded console mode (startup banner, styled
tool activity — see the CLI Channel & Console Mode guide). Headings,
lists, emphasis, links, tables, and fenced code therefore remain formatted
while the response is still arriving. Tool start/completion events are
displayed inline, and the terminal asks before every requested operation. It
only selects the agent's one-time approval option.
RoomKit forwards each ACP text delta immediately; it does not wait for the prompt to finish. The visible granularity still depends on the agent: when Claude sends its final summary as one ACP chunk, RoomKit renders that chunk at once instead of manufacturing a fake typewriter animation.
Reading how a turn ended¶
Every ACP output owns a live response record containing the negotiated protocol
version. When the agent returns a stop reason other than end_turn, RoomKit
adds it as response_metadata["acp"]["stop_reason"]. If the prompt never
returns because of an exception or cancellation, it adds interrupted: true.
A clean turn carries neither marker.
Read the final record from the inbound result instead of relying on the last
message: a turn can end on a tool call, with no later MESSAGE in which to
persist its outcome.
result = await kit.process_inbound(message)
acp_outcome = result.response_metadata.get("acp", {})
# For a deferred HTTP-style call, wait for the turn before reading the record.
deferred = await kit.process_inbound(message, defer_delivery=True)
if deferred.delivery is not None:
await deferred.delivery.wait()
acp_outcome = deferred.response_metadata.get("acp", {})
Recent Claude models may omit visible thought text in their adaptive-thinking
mode. The example therefore forwards MAX_THINKING_TOKENS through
--thinking-tokens (default: 1024) so the adapter requests a visible fixed
thinking budget. Set it to 0 for lower latency with reasoning disabled:
The model is pinned at startup with --model (forwarded as
ANTHROPIC_MODEL, the adapter's highest-priority model source) and switched
mid-session with the example's own /model command, which routes through
set_config_option() so the console's status bar follows:
❯ /model
Model: sonnet
Available: default, opus[1m], claude-fable-5[1m], sonnet, haiku
❯ /model opus[1m]
Model: opus[1m]
Its Room wiring follows the same pattern as the other CLI examples:
cli = CLIChannel("you", show_thinking=True, markdown=True)
claude = ACPChannel(
"claude-code",
command=["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.61.0"],
cwd=workspace,
external_tool_handler=TerminalPermissionHandler(),
)
kit.register_channel(cli)
kit.register_channel(claude)
await kit.attach_channel(room_id, "you")
await kit.attach_channel(
room_id,
"claude-code",
category=ChannelCategory.INTELLIGENCE,
)
await cli.run(kit, room_id=room_id)
Two agents in one Room¶
examples/acp_multi_agent.py puts Claude Code and Codex in the same Room
and the same working directory, so you can have one write code and the other
review what landed on disk:
❯ @claude-code write hello.py
❯ @codex what did claude just do?
❯ /agent # keyboard menu: pick the agent you address
Two settings make this work, and no routing rules at all:
# An agent's own output solicits nobody it did not address itself. Set on the
# room that turned multi-agent, not on the kit: a kit-wide default would also
# silence the single-agent rooms a server hosts alongside it.
await kit.set_agent_response_policy(room_id, AgentResponsePolicy.ADDRESSED_ONLY)
# Every submission names its recipient (RFC §19.3).
await cli.run(
kit,
room_id=room_id,
addressed_to=lambda _line: [addressed.agent_id],
...
)
- Only the addressed agent runs.
addressed_tonames it on the event andEventRouterskips every other intelligence channel — ahead of any routing decision (RFC §19.4 step 0). - Agents do not trigger each other. Under
ADDRESSED_ONLYan agent's own output solicits only what it addressed itself. Without that, the first answer would reach the second agent, whose answer would come back to the first, untilmax_chain_depth(5) stopped it.
Catching up on what it missed¶
A non-addressed agent is skipped entirely — not asked, and not told (RFC
§19.3.2). For an AIChannel that costs nothing: its context is rebuilt from
the store every turn. An ACP agent keeps its history inside its session, so
what it was not told would be gone for good — and the failure is quiet. Asked
whether it saw the previous message, it answers yes, in good faith, about its
own session preamble.
So ACPChannel reads the room's timeline the moment it is addressed, and
prefixes what it missed to the prompt:
[Room context — 2 messages you did not receive. Context only; the request follows.]
[1] Marie: “on part sur quoi ?”
[2] @claude-code: “I wrote hello.py”
[End of room context]
what did claude just do?
- The request names its sender. When the agent's visible window and the
request hold several speakers, the request opens with its sender's label
(
@sms1: "Alice: approve it"for an unnamed sender who wroteAlice:), so it does not read as the person the room context named (RFC §6.4). Once a session was sent a labelled request, every later request in it is labelled, whatever the loaded window holds, and the note that says how labels read comes with the first; a new session starts over. A one-to-one room's request is sent as it is, and an instruction from the application keeps its mark. - Only the gap. What arrived since this agent's last prompt, never what its session already holds — an ordinary back-and-forth carries no block at all.
- Only what it may see. The block is filtered per reader (RFC §7.5 rule 8), so a message scoped away from the agents stays out of every session. Catching up is not a second door into the room.
- Quoted, one line each. Each message is quoted on its line, bounded at 4000 characters, every double quote mark inside it made a single one, and a person's name keeps to a name's characters: no message can end the block or pose as another (RFC §6.4).
- Honest about its bound.
room_history(default 20) caps the block, and the header says so when it bites: "the 20 most recent of 47 messages you did not receive" when the loaded tail holds more than it shows, or "the 20 most recent messages you did not receive; up to 27 earlier room events were not loaded" when the tail itself stops short of what the agent missed, which is the common case on a room with no hook (it loads exactly the declared window). The second count is an upper bound: it counts room events, some of which the agent would not have been shown. An agent that knows it was truncated can ask for the rest; one that believes it holds the whole room cannot. - Its own words stay out, except a reply it gave in a standalone turn (see below), which its room session never held. A session closed and reopened starts over: the new one has missed everything.
recent_events_window follows room_history. The framework sizes the room tail
it loads to the largest window any bound channel declares, under a floor it keeps
for hooks (50 events, while a hook is registered) — so the default reads a tail
that was loaded anyway, and raising room_history past the floor grows the tail
the catch-up draws on. Declaring the window is what keeps the tail loaded on a
room with no hook at all.
Directing the agent: instructions¶
An INSTRUCTION (orchestration guide)
reaches the agent marked as the application's direction, never as a
participant's line, and the reply records its fingerprint in
metadata["instruction"]. The session keeps it once prompted: nothing is
rebuilt there, so the mark is what lets later turns read it for what it was.
A standalone instruction cannot empty the room's session, so it runs in a session opened for that turn and closed after it:
- no catch-up is sent, and the room's session is neither prompted nor told —
its catch-up still waits for the next ordinary turn, and carries the
standalone reply as the agent's own words (
you (in a separate session)), since that session never held it; - the turn session takes the room's setup
as the room's next session would (a refused value fails the turn), then the
room session's configuration (
model,mode) where the agent accepts it, and host-contributed blocks still open the prompt; cancel(room_id)stops it like any other turn of the room;- its
session/newsaysroomkit.live/sessionScope: "turn", so a transport that files sessions by room keeps it apart from the room's.
Opening a session costs what it costs on your agent (MCP servers reconnect), so
keep standalone for passes that need a blank page, such as a summary re-run.
Closing one is optional in ACP: session/close goes only to an agent whose
initialize announces sessionCapabilities.close (the Claude and Codex agents
do). An agent that does not keeps every standalone turn's session until the
connection closes; the channel never prompts it again, and logs a warning per
turn.
Standing instructions and identity¶
An ACP agent's system prompt is its own (Claude Code's, Codex's), not
RoomKit's to set. instructions give it the integrator's standing direction
instead (a persona, how the room works), and name, role and description
the identity an Agent has:
dev = ACPChannel(
"dev",
command=["npx", "--no-install", "@agentclientprotocol/claude-agent-acp@0.61.0"],
cwd=Path("/srv/checkout"),
name="Developer",
description="reads the source code and the support tickets",
instructions="You are @dev, a developer in an incident room. Answer briefly, "
"and change nothing in the workspace.",
)
The instructions open the first prompt of each session the channel opens for a
room (a session opened again after the agent restarted included), marked as
the application's, and are not sent again while the session keeps them; a
standalone turn's session gets them too. They are not an INSTRUCTION (above),
which directs one turn. What changes from turn to turn belongs to a context
contributor (below). The identity presents the agent in a
discussion: the other agents' notes, the
dispatch policy, the console's cards.
In a discussion¶
An ACP agent takes a discussion's turns as
an AI channel does: Discussion(agents=[investigator, dev, sre]) with dev an
ACPChannel. Each turn's prompt ends with the discussion's notes (who is in
the room, which message the turn answers, how the room works) under the
runtime's header, and a copy of that header in what participants wrote is
replaced. Holding the agent (listen_only) while its turn runs cancels its
prompt. Its long work is better started with kit.start_task(room_id, "dev",
...): the task runs in a session of its own, in a child room, while the
discussion goes on.
Contributing context the agent cannot fetch¶
The catch-up carries the room. It does not carry what only the host holds —
the member's saved notes, a document corpus, the rules of the organisation the
room belongs to. An AIChannel has a MemoryProvider for that, because it
rebuilds its message list every turn; an ACP session holds its history in the
agent's process and has no such seam. context_contributor is that seam:
async def host_context(context: RoomContext, trigger: RoomEvent) -> list[str]:
"""Blocks for *this* request. Awaited once per solicited turn."""
notes = await corpus.search(Channel.extract_text(trigger), room_id=trigger.room_id)
return [f"[Host context] {note}" for note in notes]
ACPChannel("codex", command=[...], cwd=workspace, context_contributor=host_context)
The blocks open the prompt, ahead of the catch-up and the request — background sits further from the question than what the agent missed of the conversation:
[Host context] The client's contract renews 2026-09-01.
[Room context — 1 message you did not receive. Context only; the request follows.]
[1] Marie: “chase the renewal”
[End of room context]
draft the renewal email
Five things this contract says out loud.
A contributor that raises costs its blocks, not the turn. The failure is logged and the prompt goes without them, like the other host-supplied callbacks in this channel. Losing the answer because a corpus was unreachable would be the worse trade.
Turn-scoped context only. The session keeps what it was already told, so a
block that never changes is paid for again on every turn. What is stable belongs
to the agent's own configuration — AGENTS.md, CLAUDE.md, an MCP server. This
is not a system prompt: ACP has no instruction channel, and everything here is
conversation the agent reads.
Nothing is bounded. RoomKit does not truncate the blocks — it knows neither
their unit nor the agent's tokenizer, and the model can change mid-session
(set_config_option) — and does not bound how long the contributor takes. Both
budgets are yours. Note that on_event runs inside the broadcast pipeline: a
slow contributor delays delivery for the whole room, not just for this agent.
RoomKit cannot filter what you put in. The catch-up is filtered per reader because it is made of room events (RFC §7.5 rule 8). Your blocks are not events, so nothing checks them — do not route through them what visibility withheld.
context.recent_events is the framework's tail, not a window you are
promised. It holds the largest window any channel bound to the room declares,
floored at 50 events while a hook or an identity hook is registered, and capped
at 2000. With room_history=0 on a room
with no hook and no other channel reading history, it holds the triggering event
alone. A contributor that needs the room's history reads it from the store
(kit.store.get_conversation(room_id, limit=...)).
Event mapping¶
| ACP update | RoomKit representation |
|---|---|
| Agent message chunk | Text delta in the normal response stream |
| Agent thought chunk | ThinkingDeltaMarker and ephemeral thinking events |
| Tool call start/terminal status | Persisted tool-call markers and ephemeral activity |
| Intermediate tool progress | Ephemeral CUSTOM event with type="acp_tool_progress" |
| Plan update | Ephemeral CUSTOM event with type="acp_plan_update" |
| Usage update | Ephemeral CUSTOM event with type="acp_usage" |
Generated text and tool activity continue through RoomKit's regular streaming pipeline. Visibility, chain-depth limits, persistence, hooks, and re-broadcast therefore behave like other intelligence-channel output.
The end of a turn is announced¶
An agent that owns its own turn still reports it finished. When a turn reaches
its end, the channel fires ON_AI_RESPONSE with the text it produced, how many
tools it called, and how long it took — the same trigger, and the same
AIResponseEvent, an in-process AI channel fires. response_content is the
turn's transcript: every text segment the agent produced, separated by a blank
line where a tool call cut them, and segments carries them one by one, so
segments[-1] is the final report without the narration before it.
Post-processing an integrator hangs off that hook therefore runs for a
conversation an agent held:
@kit.hook(HookTrigger.ON_AI_RESPONSE, execution=HookExecution.ASYNC)
async def summarize(event, ctx: RoomContext) -> None:
logger.info(
"%s answered in %sms with %d tool calls (%s tokens)",
event.channel_id,
event.latency_ms,
event.tool_calls_count,
event.usage.get("total_tokens"),
)
Two things are worth knowing about that event.
Only a finished turn reports. A stream closed from the outside — its consumer cancelled, a muted binding dropping it — cancels the agent and delivers nothing to the room, so it is not a response and fires nothing. Nor does a turn that ended in an error.
usage is the agent's own accounting, relayed unaltered. The token
counters come off the prompt's response; the context occupancy and running
cost come off the usage notifications the session sends:
{
"input_tokens": 2, "output_tokens": 3, "total_tokens": 27369,
"cached_read_tokens": 16997, "cached_write_tokens": 10367,
"context_used": 27369, "context_size": 1000000,
"cost": 0.1128, "currency": "USD",
}
Read total_tokens. A coding agent's context arrives almost entirely as cache
reads, so the numbers above are a real turn: 2 uncached input tokens against
27369 actually accounted for. Any per-turn cost computed from input_tokens
alone is off by four orders of magnitude.
The ACP schema annotates those counters as running session figures ("total
input tokens across all turns"), while the reference agent fills them per
prompt — measured against it, cached_read_tokens is the whole prefix re-read
on that turn rather than a sum over turns. A client cannot tell the two apart
from one reading, and reinterpreting either way corrupts the figure where
nothing downstream can notice, so RoomKit does no arithmetic on them at all. If
an integrator knows which convention its agent follows, that is where the
subtraction belongs. cost is a session cumulative amount. context_used and context_size
describe occupancy and capacity, not consumed tokens; they may decrease after
compaction or a configuration change.
An agent that reports no usage at all leaves usage empty rather than
inventing zeros.
Usage provenance and recovered results¶
AIResponseEvent.usage_metadata is optional and defaults to {} for existing
providers. ACP fills it independently of the numeric usage map:
{
"protocol": "acp", "transport": "stdio",
"session_id": "native-session", "event_id": "host-trigger-event",
"prompt": {
"source": "session/prompt", "scope": "unspecified",
"model_at_start": "opus", "stop_reason": "end_turn",
},
"usage_report": {
"report_id": "local-receipt-id", "identity_source": "roomkit",
"observed_at_ms": 1780000000000,
"source": "session/update", "scope": "session",
"model_at_observation": "opus",
"update": {
"sessionUpdate": "usage_update", "used": 0, "size": 200000,
"cost": {"amount": 0, "currency": "USD"},
},
},
}
event_id identifies the host request being observed; it does not identify
the source prompt of the session cost. The cost is cumulative for the session;
used and size describe context occupancy/capacity, which can decrease after
compaction. Token counters come from session/prompt, but their accounting
scope remains unspecified: RoomKit cannot infer an adapter's convention.
The model snapshots describe when the model was observed, not which model
incurred the entire cumulative cost. Absent fields stay absent; a reported
zero stays zero. No cost delta, tariff or credits are computed.
A standard ACP usage notification names a session, not a prompt. RoomKit gives
its receipt a local ID and timestamp, marked identity_source="roomkit";
it never manufactures source_result_id. Such an ID is not durable across
reconnection/replay, and a missing session epoch establishes no continuity
across a reset. Notifications between prompts are published through CUSTOM
acp_usage with the same usage_metadata, but are not carried into the next
prompt's response. Even a notification received during a prompt remains a
session observation, never a claim that this prompt caused the cost.
A transport that owns and validates durable facts opts in by overriding
ACPTransport.provides_usage_metadata to return True. It can then put them
in the SDK extension
_meta["roomkit.live/usage"] (field_meta in Python), on a PromptResponse
and on each UsageUpdate it relays. This envelope carries the available
session_id, session_epoch, usage_protocol, node_id, agent_id,
adapter_info (the adapter handshake identity, relayed unchanged),
result_id, turn_id, generation, replayed, and usage_report. The report
has report_id, observed_at_ms, source="session/update", scope="session",
optional source_result_id, and the raw update. A terminal model, if
known, is copied to prompt.model; current configuration is never used to
label a recovered result's tokens. The transport validates these facts and
supplies node/adapter identity from its authenticated connection. These reported
metadata are not authorization credentials. On a native connection, RoomKit
also includes the available adapter_info from the ACP handshake. The default
is False: stdio and other transports that do not opt in ignore this extension
for identity attribution, so an agent cannot promote its own metadata into
authenticated transport provenance.
A terminal envelope is authoritative, including an absent report: RoomKit
discards any live cost that would otherwise be paired with the recovered
result. The snapshot is sealed before the terminal stream item is consumed;
later notifications remain ephemeral and cannot change the hook report.
It copies identities unchanged and marks identity_source="transport".
A report's source_result_id may differ from the response's result_id, or be
absent: keep that distinction when persisting or deduplicating. Live
notifications never establish the current prompt's result identity. Scope
report IDs by their node, adapter, native session and epoch when available.
Old transports need no new methods and can omit this extension entirely.
Interruption policy: a consumed PromptResponse still fires the existing
hook, including cancelled, refusal or max_tokens; prompt.stop_reason
records the distinction. A response observation is not proof of successful
work. Exceptions and abandoned streams fire no response hook. Reports already
received remain available to subscribers of CUSTOM acp_usage; a missing
terminal report is never fabricated. Hosts needing durable error/restart
accounting must persist those observations or recover a transport's durable
result. The hook itself is not an exactly-once accounting journal.
A turn never outlives its tool calls¶
An agent that disappears mid-tool — its process restarted, its host gone — sends no terminal update for the call it was running. The tool-call start is already persisted, so nothing would ever close it and the card reads as running on every reload of the conversation, indefinitely.
However a turn ends, therefore, every tool it started and left unfinished is
closed first: a tool-call end with status failed, carrying an error that says
the turn ended before the tool reported a result. That distinction is for
whoever reads the thread later — a tool that never returned because the turn
died is not a tool that failed on its own. Cancellation goes the same way: a
stop the user asks for returns through the ordinary end of a prompt, and takes
the open tool with it. A turn whose tools all reported adds nothing.
One case stays open by construction. A response stream closed from the outside — its consumer cancelled, a muted binding dropping it — is past the point where anything can be added to it, so the live surfaces get their ephemeral tool-call end and the stored row stays pending.
What a tool result shows¶
ACP fixes the envelope — content blocks and a free-form raw_output — and
leaves the payload inside it to each agent. Two agents in one room therefore
answer in two dialects, and console mode reads both:
| The agent sends | The console shows |
|---|---|
A text content block (Claude Code) |
Its lines |
A diff block with old/new text |
Colored ± lines under the file path |
raw_output: {"formatted_output": …, "exit_code": N} (Codex) |
The command's output — exit code N when a failure printed nothing |
A terminal block — a handle on live output, carrying no text |
Whatever raw_output holds, since the block itself has none |
An MCP {"result": {"content": […]}, "error": …} wrapper |
The result's text, or the error |
An image, audio, or resource block |
The medium, named — base64 is never printed |
| Anything else | Compact JSON, capped |
Previews are capped at five lines with a … +N lines marker, and each line at
200 characters. Output that merely happens to be JSON — cat package.json —
keeps its own lines instead of being taken apart.
Permissions¶
ACP agents may ask the client to approve a tool. ACPChannel rejects these
requests by default. To approve selected operations, provide an
ExternalToolHandler:
The agent decides what it asks
RoomKit's gate hears only of the calls the agent submits, and the agent's
own configuration decides which those are. Measured with
claude-agent-acp 0.61.0: in Claude Code's auto mode (a user's
~/.claude/settings.json sets it, and an agent spawned with that HOME
inherits it, its allow rules and skills too) the agent runs commands
without asking, and no permission request reaches the handler; in
default mode a command that writes is asked for (and refused by a
read-only policy), while reads and commands it deems safe run unasked;
in plan mode it refuses every action that is not a read itself. Set the
mode you rely on for every session with
ACPChannel(session_config={"mode": "default"}), or for one room with
await agent.set_config_option(room_id, "mode", "default"), which the room
keeps across a recovery or an agent restart
(Setting up each room's session).
from roomkit import ACPChannel, ToolPolicy
from roomkit.tools import PolicyExternalToolHandler
permissions = PolicyExternalToolHandler(
policy=ToolPolicy(
allow=["Read *", "Search *"],
deny=["*delete*", "*credential*"],
)
)
agent = ACPChannel(
"coding-agent",
command=["my-acp-agent", "--stdio"],
cwd="/srv/workspaces/my-project",
external_tool_handler=permissions,
)
RoomKit injects BEFORE_TOOL_USE and ON_TOOL_CALL hooks into the handler when
the channel is registered. The policy refuses first, with the text every gate
gives (Tool 'X' is not permitted by the agent's tool policy.), so an approval
hook is never asked about a tool it may not run; BEFORE_TOOL_USE then decides
what the policy admits. An approval chooses allow_once when the agent
offers it; RoomKit does not silently grant durable permissions.
Every call the agent runs reaches ON_TOOL_CALL once it ends, as a report on
a call the agent already read, with or without an external handler. Its stored
outcome is the one every channel names: cancelled when the turn ended under
it (a Stop, a process restart), refused when RoomKit refused the agent's
permission request for it, else failed or served as the agent reported.
With an external handler, a call the turn ended under reaches it through
on_tool_cancelled rather than on_tool_result; by default that reports it
cancelled. A permission request runs in the connection's own task, so
cancelling the turn does not cancel a pending process_tool_call: an override
resolves it itself (it denies the approval still pending) before calling
super().on_tool_cancelled(...).
A call that never ran reaches ON_TOOL_CALL's ASYNC observers only, never a
SYNC hook: a rejected permission is marked refused, a call the turn ended
under cancelled. With an external handler, its own refusal comes back to it
through on_tool_refused(tool_name, tool_input, reason, ...), which reports it
so by default; a handler that raises while deciding fails the call, and the
channel reports it with what failed (error_detail). The report carries the
same body with a handler as without one.
A refusal that came from a failure (a BEFORE_TOOL_USE hook that failed
closed) carries what failed: ToolDecision.detail reaches
on_tool_refused(..., detail=...) and the report's error_detail. An agent
that runs a call RoomKit rejected anyway (refused, or rejected because the
handler raised deciding it), and closes it completed, is reported as it
ended, served, with refused_but_ran=True on the report, its end row and its
tool_call framework event; the handler hears it through
on_tool_result(..., refused_but_ran=True, error_detail=...), error_detail
being what failed in the rejection, which the report carries whether the
handler or the channel makes it. These keywords are passed only when they
apply, and only to an override that takes them: write an on_tool_refused
override with **kwargs handed to super().on_tool_refused, and an
on_tool_result override with **kwargs handed to _fire_on_tool_hook,
whose keywords they are (on a direct ExternalToolHandler subclass,
super().on_tool_result is abstract and reports nothing). An override that
cannot take refused_but_ran leaves that call's report to the channel, so the
marker is never lost; one that takes it without error_detail reports the
call without what failed, and the log says so.
A handler serves one channel — that is what makes the injected hooks attributable — and it can say which, so a prompt can name who is asking:
async def process_tool_call(self, tool_name, tool_input, **kwargs):
answer = await terminal_input(f"@{self.channel_id} wants {tool_name}. Allow? [y/N] ")
...
channel_id is empty until the channel is registered (handlers are built
first), so read it when a tool call arrives, not in __init__. Wiring the
same instance to a second channel logs a warning and re-attributes its tool
events — give each channel its own handler.
Warning
ACP v1 tool calls expose a human-readable title and a coarse tool kind, not
one universal canonical tool name across agents. Treat title-based glob
policies as adapter-specific. For sensitive environments, implement a
custom ExternalToolHandler that validates the title and structured input,
and keep the default-deny behavior.
Client-side filesystem and terminal callbacks are not implemented or advertised. The external agent still operates according to its own sandbox and the permission options it presents.
Cancellation and lifecycle¶
# Notify the agent that the active turn should stop.
cancelled = await agent.cancel("coding-session")
# Forget one Room's ACP session but keep the process for other Rooms.
closed = await agent.close_session("coding-session")
# RoomKit.close() closes all sessions and the subprocess.
await kit.close()
cancel reaches a turn from the moment it holds the room: while the agent is
started and its session opened or set up (which takes a second or more with
MCP servers), the turn ends without prompting, its response metadata marked
interrupted, and returns True. A queued turn, still waiting for the room,
is not the active one.
close_session forgets the session whatever the agent answers, and never
raises for a refused close: session/close goes only to an agent that announces
it, and one that does not keeps the session until the connection closes. A
custom transport whose initialize reply carries no agent_capabilities at
all keeps receiving it; a relay that needs it to drop a session announces it.
Session identifiers are process-local in this first implementation. A restart creates new sessions; persistent ACP load/resume is not yet enabled.
Retiring a channel object without cutting its turn¶
close() ends whatever turn the channel is running: an ACPChannel cancels
it on both sides of the wire, an AIChannel tears its provider down under it.
A channel object taken out of the registry — displaced by
unregister_channel + register_channel on a rebuild, or removed with the
agent it served — may still be answering for a turn that captured it, so the
caller waits for the channel's own word before closing it:
old = kit.unregister_channel("agent:coder")
kit.register_channel(rebuilt)
while old.active_turns: # 0 once every turn it was producing has ended
await asyncio.sleep(10)
await old.close()
Channel.active_turns counts the turns a channel is producing, from their
first consumption to their end. ACPChannel counts from the prompt going out
until the stream closes; AIChannel counts a tool loop through its steering
registry and a text-only stream through its generator. A channel that does not
count answers 0 and is treated as idle — keep a floor before the first read,
and a ceiling on the wait, in the caller. Both channels also report the count
in info["active_turns"].
Current scope¶
- Stable ACP protocol v1, over stdio or any transport you supply
- Text and rich-text Room input
- Streaming text, thoughts, tool lifecycle, plans, and usage
- Permission requests through
ExternalToolHandler - Per-Room session isolation, cancellation, and deterministic cleanup
Not yet included:
- ACP server mode for RoomKit-built agents
- Client-side filesystem, terminal, or elicitation implementations
- Image/audio prompt blocks
- Persistent session resume
- Experimental ACP protocol versions or draft transports