Skip to content

Otter ACP Server

The Agent Client Protocol (ACP) is a JSON-RPC 2.0 wire format that lets external IDE / TUI clients drive an agent session over stdio without bespoke plumbing. Otter ships an ACP server transport so clients like Zed (and any other ACP-aware IDE) can drive a Chimera session through the same surface they’d use against the upstream open-source coding agent.

This is the inverse of chimera/acp/client.py: where the client spawns an external ACP-speaking agent and drives it as a subprocess, chimera/otter/acp.py is the agent — it accepts requests on stdin and emits responses + session/update notifications on stdout.

Terminal window
# Start an ACP server on stdio.
chimera otter serve --acp
# Or pass directly via stdin/stdout to another tool.
some-ide | chimera otter serve --acp | some-ide

The server takes the standard otter flags (--cwd, --model, --allowed-tools) and runs until the input stream closes.

  • Transport. Newline-delimited JSON-RPC 2.0. One JSON object per line, no Content-Length headers, no SSE framing.
  • Requests carry an integer id; the server responds with the same id.
  • Notifications (server → client) have no id and use the method session/update.

Example request → response round-trip:

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"0.1","capabilities":{}}}

The server implements the core ACP method set:

MethodPurpose
initializeCapability handshake.
session/newCreate a new session; returns sessionId.
session/loadLoad a previously persisted session by id.
session/listList known sessions.
session/messageSend a user message; emits session/update events; replies with the final assistant message.
session/cancelCancel an in-flight turn.
session/setModeSwitch the active agent (build / plan / etc.).
session/setModelSwitch the active model.
permission/respondReply to a pending permission request (allow / deny / always).

Unknown methods return JSON-RPC error code -32601 (Method not found).

The server pushes session/update notifications on the same stream during a turn. The shape mirrors the upstream agent:

{
"jsonrpc":"2.0",
"method":"session/update",
"params":{
"sessionId":"01J9...",
"update":{
"sessionUpdate":"agent_message_chunk",
"content":[{"type":"text","text":"..."}]
}
}
}

Five sessionUpdate kinds are emitted:

  • agent_message_chunk — streaming text from the assistant.
  • tool_call — a tool was invoked (with tool name, input, and callId).
  • tool_result — the tool returned (success or error, with callId correlation).
  • usage_update — token counts and cost so far.
  • permission_request — the agent asked for permission to run a risky tool. The client replies via permission/respond.

When a tool call requires user confirmation (per the active permission ruleset), the server emits a permission_request notification:

{"sessionUpdate":"permission_request","requestId":"r1","tool":"bash","input":{"cmd":"rm -rf /tmp/x"},"options":["allow","always","deny"]}

The client replies with:

{"jsonrpc":"2.0","id":42,"method":"permission/respond","params":{"requestId":"r1","reply":"allow"}}

The server resolves the pending future and the turn continues. The client is responsible for surfacing the prompt to the user; otter does not assume any particular UI.

Each session is backed by a Chimera Session instance with its own agent loop, message queue, and cancellation token. Sessions persist across session/load calls via ~/.chimera/eventlog/otter-<id>/ (the same directory layout used by chimera otter sessions).

State machine per session:

  • idle — no in-flight turn; ready for session/message.
  • running — a turn is in progress; emitting session/update.
  • cancelledsession/cancel arrived; the turn is unwinding.

A second session/message arriving while the session is running queues behind the active turn (mirrors the upstream behavior).

JSON-RPC error codes used by the server:

CodeMeaning
-32700Parse error (malformed JSON)
-32600Invalid request
-32601Method not found
-32602Invalid params
-32603Internal error
-32000Application-level error (with data payload describing the agent error)

The agent never crashes the server on internal errors; they’re caught and surfaced as -32000 responses with a structured data payload ({name, message, stack}).

session/cancel flips the session’s CancellationToken, which unwinds the current tool call cooperatively (per chimera/core/cancellation.py). The server replies to the in-flight session/message request with a final agent_message_chunk carrying the partial assistant output, plus a marker indicating the turn was cancelled.

initialize advertises the server’s capabilities so the client can adapt:

{
"protocolVersion":"0.1",
"serverInfo":{"name":"otter-acp","version":"0.1.0"},
"capabilities":{
"sessionFork":true,
"sessionList":true,
"permissionRequest":true,
"thinking":false,
"vision":false
}
}

The version string follows the upstream ACP spec; clients should validate the major version and gracefully handle minor differences.

Otter does not currently honor the upstream --pure flag (no external plugins). To get the same effect, narrow the tool surface with --allowed-tools:

Terminal window
chimera otter serve --acp --allowed-tools Read,Grep,Bash

tests/otter/test_acp.py covers:

  • The full initializesession/newsession/messagesession/cancel flow against an in-process server.
  • Permission round-trip: pending request blocks the turn until permission/respond arrives.
  • Unknown method returns -32601.
  • Malformed JSON returns -32700 and the server keeps the stream alive.
  • Concurrent session/message calls queue per session.

The HTTP server (chimera otter serve --http) honors the SSE Last-Event-ID header so a reconnecting client picks up exactly where it left off. ACP runs over stdio — there’s no HTTP header surface to read — so the equivalent is a plain JSON-RPC method:

{"jsonrpc":"2.0","id":7,"method":"session/resume","params":{"sessionId":"otter-...","sinceEventId":2}}

The server replays every retained session/update notification whose monotonic eventId is strictly greater than the cursor, in original order, then returns:

{
"sessionId":"otter-...",
"replayed":3,
"lastEventId":5,
"truncated":false
}

Replayed notifications come back as plain session/update frames (same wire shape as the live stream) so the client’s normal handler can process them with no second code path.

Every session/update notification carries an eventId field — a 1-based monotonic counter scoped to the session. The first emitted notification is eventId: 1, the second eventId: 2, and so on. The counter is independent of JSON-RPC request ids.

{
"jsonrpc":"2.0",
"method":"session/update",
"params":{
"sessionId":"otter-abc",
"eventId":17,
"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"..."}}
}
}

The server keeps the most recent OTTER_ACP_DEFAULT_HISTORY_SIZE (default 1024) notifications per session. Older entries are evicted FIFO so a long-lived session doesn’t grow unbounded. If the client’s sinceEventId cursor is older than the oldest retained id, the resume reply sets truncated: true so the client knows it may have missed events that fell out of the buffer. The cap is configurable via the history_limit constructor argument on OtterACPServer.

  • sinceEventId: 0 (or omitted) replays everything still buffered.
  • sinceEventId at or above the latest id replays nothing.
  • Negative cursors clamp to 0.
  • Non-integer cursors are treated as 0 (full replay).

initialize advertises the new resume surface so clients can feature-detect:

{
"agentCapabilities":{
"promptCapabilities":{"text":true},
"sessionCapabilities":{"cancel":true,"resume":true},
"toolApproval":true,
"eventIds":true
}
}

OTTER_ACP_PROTOCOL_VERSION is 2 for builds that ship the resume method; clients negotiating against version 1 should fall back to full-replay reconnects.

ACP runs exclusively over stdio in this implementation — the server reads JSON-RPC frames from stdin and writes them to stdout. There is no TCP listener, so TLS is not applicable at this layer. Clients that need transport security should wrap the subprocess invocation (e.g. SSH, mTLS over a Unix-domain socket relay) in their own launcher. The HTTP server (chimera/otter/server.py) is the surface that exposes a --tls flag.

  • chimera/otter/acp.py — implementation.
  • chimera/acp/client.py — the inverse: ACP client that drives an external agent.
  • docs/otter/parity-matrix.md — overall parity status.
  • The Agent Client Protocol reference (search for “agent client protocol” in your IDE’s docs; the spec is published by the upstream ecosystem and is implementation-language-neutral).