Chapter 3: Agent Client Protocol (ACP)¶
In Chapter 2: Twinki we saw how the TUI renders a beautiful terminal interface with differential line updates. But where do the messages come from that it renders? They arrive over ACP — the Agent Client Protocol.
Motivation¶
kiro-cli is split into two processes:
| Process | Language | Job |
|---|---|---|
| Rust backend | Rust | LLM calls, tool execution, session persistence, MCP servers |
| TUI frontend | TypeScript (React) | Rendering, user input, terminal management |
Why not one process? Because each language excels at different things. Rust gives you raw speed for streaming LLM tokens, running tools in parallel, and managing memory-mapped session files. TypeScript (with React and Twinki) gives you a declarative, component-based UI that's easy to iterate on. ACP is the contract that lets them evolve independently — you can redesign the entire TUI without touching the agent engine, or swap the LLM provider without changing a single React component.
Analogy: Think of ACP as a waiter's notepad that both the dining room and the kitchen understand. The TUI is the dining room — it takes the customer's order (user prompt) and writes it on the notepad. The Rust backend is the kitchen — it reads the notepad, cooks the meal (calls the LLM, runs tools), and writes the result back. Neither side needs to know how the other works internally; they just need to agree on the notepad format.
Use Case: "Fix this bug"¶
Let's trace what happens when a user types "fix this bug" and presses Enter:
- The TUI captures the keystrokes and wraps them into a JSON-RPC request
- It writes that request to the Rust process's stdin as a single line of JSON
- The Rust backend reads the line, routes it to the correct session, and sends the prompt to the LLM
- As the LLM streams back tokens, the backend sends JSON-RPC notifications to its stdout
- The TUI reads each notification and updates the React component tree in real time
The user sees characters appearing one by one — but under the hood, structured JSON messages are flying back and forth over a pair of pipes.
Key Concepts¶
JSON-RPC 2.0 over stdio¶
ACP uses JSON-RPC 2.0 as its wire format. Every message is a single JSON object, and the transport is newline-delimited JSON (ndjson) over the process's stdin and stdout. One line = one message. No HTTP, no sockets — just pipes.
There are three kinds of messages:
| Kind | Has id? |
Has method? |
Purpose |
|---|---|---|---|
| Request | ✅ | ✅ | "Do something and tell me the result" |
| Response | ✅ | ❌ | "Here's the result for your request" |
| Notification | ❌ | ✅ | "FYI, something happened" (no reply expected) |
Core Methods¶
Here are the most important ACP methods:
| Method | Direction | Kind | What it does |
|---|---|---|---|
initialize |
TUI → Rust | Request | Handshake: exchange protocol version and capabilities |
session/new |
TUI → Rust | Request | Create a new conversation session |
session/load |
TUI → Rust | Request | Resume an existing session from disk |
session/prompt |
TUI → Rust | Request | Send the user's message to the LLM |
session/update |
Rust → TUI | Notification | Stream back agent text, tool calls, tool results |
session/cancel |
TUI → Rust | Notification | Cancel the current turn |
A Real Message¶
When the user types "fix this bug", the TUI sends this over stdin:
{
"jsonrpc": "2.0",
"id": 3,
"method": "session/prompt",
"params": {
"sessionId": "a1b2c3d4-...",
"prompt": [{ "type": "text", "text": "fix this bug" }]
}
}
The Rust backend processes the prompt and streams back notifications like this:
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "a1b2c3d4-...",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": { "type": "text", "text": "I'll look at the error" }
}
}
}
Notice: the notification has no id — it's fire-and-forget. The backend sends many of these as the LLM streams tokens. When the turn is done, the backend sends a response to the original request (id: 3) with a stopReason.
Extension Methods¶
Beyond the core protocol, kiro-cli defines custom extension methods prefixed with _kiro.dev/. These carry UI-specific events like slash command results, MCP server status, context usage metrics, and agent switching notifications. The leading underscore signals "this is a vendor extension, not part of the base ACP spec."
_kiro.dev/commands/available — advertise slash commands
_kiro.dev/metadata — context usage percentage, metering
_kiro.dev/mcp/server_initialized — MCP server ready
_kiro.dev/agent/switched — agent mode changed
How It Flows¶
Here's the full sequence for a single user turn:
sequenceDiagram
participant User
participant TUI as TUI (TypeScript)
participant ACP as stdin/stdout pipes
participant Backend as Rust Backend
participant LLM
User->>TUI: types "fix this bug" + Enter
TUI->>ACP: session/prompt (JSON-RPC request)
ACP->>Backend: ndjson line on stdin
Backend->>LLM: stream prompt to model
LLM-->>Backend: token stream
Backend-->>ACP: session/update notifications (stdout)
ACP-->>TUI: ndjson lines parsed
TUI-->>User: characters appear in terminal
Backend->>ACP: prompt response (stopReason: endTurn)
ACP->>TUI: JSON-RPC response (id matches request)
The key insight: requests flow inward (TUI → Backend) and notifications flow outward (Backend → TUI). The response to the original request only arrives after the entire turn is complete.
Internal Implementation¶
TypeScript Side: AcpClient¶
File: packages/tui/src/acp-client.ts
The AcpClient class is the TUI's gateway to the Rust backend. On construction, it either:
- Spawns the Rust binary (
kiro-cli acp) as a child process with piped stdio, or - Connects to an existing ACP process via a Unix socket (used by Kanvas and other embedders)
Either way, it wraps the transport in an @agentclientprotocol/sdk ClientSideConnection that handles JSON-RPC framing, request/response correlation, and method dispatch.
Key methods map directly to ACP protocol methods:
// acp-client.ts (simplified)
async initialize() // → sends initialize request
async newSession() // → sends session/new request
async loadSession(id) // → sends session/load request
async prompt(blocks) // → sends session/prompt request
async cancel() // → sends session/cancel notification
Incoming notifications arrive via the acp.Client interface. The SDK calls sessionUpdate() on the client whenever a session/update notification arrives. AcpClient converts the raw ACP update into a domain-specific AgentStreamEvent and broadcasts it to all registered handlers (the Zustand stores that drive React rendering).
The ndjson parsing deserves a note: rather than using the SDK's built-in stream reader (which stalls under Bun with concurrent React rendering), AcpClient manually parses data events from the Node.js stream, splitting on newlines and JSON-parsing each line. This is a pragmatic workaround for a real concurrency issue.
Rust Side: AcpSession Actor¶
File: crates/chat-cli-v2/src/agent/acp/acp_agent.rs
On the Rust side, each conversation is an AcpSession — a tokio actor that owns:
- An
Agenthandle (the LLM execution engine) - A
JrConnectionCx(the JSON-RPC connection to the TUI) - A
SessionDb(on-disk persistence)
The actor runs a main_loop that select!s between two event sources:
- ACP requests from the TUI (via a channel from the SACP server)
- Agent events from the LLM engine (tool calls, text chunks, turn completion)
When a session/prompt request arrives, the actor spawns a task to feed it to the Agent. As the Agent produces events, the actor converts them to ACP SessionUpdate notifications and sends them back over the connection. When the Agent signals EndTurn, the actor responds to the original request.
Rust Side: Protocol Schema¶
File: crates/chat-cli-v2/src/agent/acp/schema.rs
Extension methods are defined as Rust structs with derive macros (JrRequest, JrNotification, JrResponsePayload) from the sacp crate. These macros auto-generate the JSON-RPC method routing and serialization:
// schema.rs (simplified)
#[derive(JrRequest)]
#[request(method = "_kiro.dev/commands/execute",
response = CommandExecuteResponse)]
pub struct CommandExecuteRequest {
pub session_id: String,
pub command: TuiCommand,
}
The method attribute in the derive macro is the exact string that appears on the wire. The sacp server framework matches incoming JSON-RPC methods to these structs and dispatches them to the registered handlers.
Rust Side: The SACP Server¶
File: crates/chat-cli-v2/src/agent/acp/acp_agent.rs (bottom — execute function)
The execute() function at the bottom of acp_agent.rs is the entry point for kiro-cli acp. It builds an AgentToClient SACP server with chained .on_receive_request() handlers — one per ACP method. Each handler dispatches to the SessionManager (which we'll cover next chapter) and returns immediately to avoid blocking the JSON-RPC dispatch thread.
⚠️ Deadlock warning: The SACP framework processes requests and responses on the same connection. If a request handler blocks waiting for a response from the same connection, you get a deadlock. That's why every handler in
execute()hands off work to the SessionManager via channels and returns immediately.
Key Files Summary¶
| File | Role |
|---|---|
packages/tui/src/acp-client.ts |
TS client — spawns/connects to Rust, sends requests, receives notifications |
crates/chat-cli-v2/src/agent/acp/mod.rs |
Rust module root — re-exports all ACP submodules |
crates/chat-cli-v2/src/agent/acp/acp_agent.rs |
Rust ACP session actor + SACP server entry point |
crates/chat-cli-v2/src/agent/acp/schema.rs |
Rust extension method type definitions |
crates/chat-cli-v2/src/agent/acp/acp_client.rs |
Rust test client for headless ACP testing |
crates/chat-cli-v2/src/agent/acp/stdin_reader.rs |
Non-blocking stdin reader (avoids tokio shutdown hang) |
Conclusion¶
ACP is the spine of kiro-cli's V2 architecture. It's a JSON-RPC 2.0 protocol over stdio that carries everything: session lifecycle, user prompts, streaming LLM responses, tool approval dialogs, and dozens of extension events. The TUI writes requests to stdin; the Rust backend writes notifications and responses to stdout. Neither side knows or cares about the other's internals.
The protocol has three layers:
1. Transport — ndjson over stdin/stdout pipes
2. Framing — JSON-RPC 2.0 (request/response/notification)
3. Semantics — ACP methods (session/new, session/prompt, session/update) plus _kiro.dev/* extensions
What's Next¶
Once ACP delivers the session/prompt message to the Rust backend, something needs to decide what to do with it — create a new session? Resume an old one? Route it to the right Agent? That's the job of the Session Manager.