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.12 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()
        return acp.connect_to_agent(client, writer, reader, queue=queue)

    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
)

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.

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.

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.

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 · sms: on part sur quoi ?
[2] claude-code: I wrote hello.py
[End of room context]

what did claude just do?
  • 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.
  • 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". 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, and 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.

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 · sms: chase the renewal
[End of room context]

draft the renewal email

Four 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.

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 envelopecontent 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:

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. An approval chooses allow_once when the agent offers it; RoomKit does not silently grant durable permissions.

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")

# Close 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()

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