Skip to content

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:

  1. The TUI captures the keystrokes and wraps them into a JSON-RPC request
  2. It writes that request to the Rust process's stdin as a single line of JSON
  3. The Rust backend reads the line, routes it to the correct session, and sends the prompt to the LLM
  4. As the LLM streams back tokens, the backend sends JSON-RPC notifications to its stdout
  5. 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 Agent handle (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:

  1. ACP requests from the TUI (via a channel from the SACP server)
  2. 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.

➡️ Chapter 4: Session Manager