Skip to content

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:

pip install "roomkit[acp]"

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_servers is 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 its env, its URL or its headers.
  • session_config maps 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, over session_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 on session_config alone.
  • A value the agent refuses fails closed. The session is released and never prompted, and the turn fails (or set_config_option raises) with a RuntimeError naming 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: a set_config_option that 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:

uv run python examples/acp_claude_code.py --thinking-tokens 0

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:

uv run python examples/acp_claude_code.py --model sonnet
❯ /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:

CONSOLE=1 uv run python examples/acp_multi_agent.py
❯ @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],
    ...
)
  1. Only the addressed agent runs. addressed_to names it on the event and EventRouter skips every other intelligence channel — ahead of any routing decision (RFC §19.4 step 0).
  2. Agents do not trigger each other. Under ADDRESSED_ONLY an 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, until max_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 wrote Alice:), 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.
ACPChannel("codex", command=[...], cwd=workspace, room_history=0)  # opt out

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/new says roomkit.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