# ACP protocol reference

> ACP mode entrypoints, event types, meta fields, stop reasons, kernel features, and protocol-facing constraints from source and tests.

- Repository: PrimeIntellect-ai/prime-agent
- GitHub: https://github.com/PrimeIntellect-ai/prime-agent
- Human docs: https://grok-wiki.com/public/docs/primeintellect-ai-prime-agent-3367c32760b1
- Complete Markdown: https://grok-wiki.com/public/docs/primeintellect-ai-prime-agent-3367c32760b1/llms-full.txt

## Source Files

- `packages/coding-agent/docs/acp.md`
- `packages/coding-agent/src/modes/acp/acp-mode.ts`
- `packages/coding-agent/src/modes/acp/acp-events.ts`
- `packages/coding-agent/src/modes/acp/acp-meta.ts`
- `packages/coding-agent/src/modes/acp/acp-stop-reason.ts`
- `packages/coding-agent/test/acp-events.test.ts`

---

---
title: "ACP protocol reference"
description: "ACP mode entrypoints, event types, meta fields, stop reasons, kernel features, and protocol-facing constraints from source and tests."
---

ACP mode (`prime-agent --mode acp`) runs Prime Agent as an [Agent Client Protocol](https://agentclientprotocol.com) agent: JSON-RPC 2.0 over newline-delimited JSON on stdin/stdout, implemented with `@agentclientprotocol/sdk` and an in-process `AgentConnection`. It does not shell out to RPC mode; IPython, subagents, autonomous gates, and related signals are mapped into standard `session/update` payloads or the reverse-domain `_meta` envelope `ai.primeintellect.prime-agent`.

## Entrypoints

| Surface | Path / symbol | Role |
|---|---|---|
| CLI | `prime-agent --mode acp` | Selects app mode `acp` via `--mode` (`text \| json \| rpc \| acp \| daemon`) |
| Main routing | `resolveAppMode` → `runAcpMode` / `runAcpModeWithConnection` | Cold process uses `runAcpMode(runtime)`; attach/connection path uses `runAcpModeWithConnection(connection)` |
| Module exports | `packages/coding-agent/src/modes/acp/` | `runAcpMode`, `runAcpModeWithConnection`, event mappers, meta helpers, stop-reason mapper |
| SDK dependency | `@agentclientprotocol/sdk` (^1.3.0) | NDJSON stream, `agent()` handler chain, `PROTOCOL_VERSION` |

```bash
prime-agent --mode acp
```

Optional CLI context used by cold-process tests: `--provider`, `--model`, `--no-session`, `--offline`, `--daemon-socket`. Stdin is **not** treated as piped prompt content in ACP mode; stdin is the protocol transport.

```ts
// packages/coding-agent/src/modes/acp/acp-mode.ts
export async function runAcpMode(runtimeHost: AgentSessionRuntime): Promise<never>
export async function runAcpModeWithConnection(
  connection: AgentConnection,
  options?: AcpModeOptions,
): Promise<never>
```

<ParamField body="bindHeadlessExtensions" type="() => Promise<void>">
Bind headless extensions once the connection is live (in-process). Invoked on the first successful `session/new` bind path.
</ParamField>

<ParamField body="stream" type="ReturnType of acp.ndJsonStream">
Transport override. Defaults to NDJSON over stdio. Tests inject an in-memory duplex pair.
</ParamField>

<ParamField body="ownStdout" type="boolean">
When `false` or when `stream` is supplied, the mode skips claiming process stdout. Production stdio path calls `takeOverStdout()` so stray writes go to stderr; protocol frames use `writeRawStdout`.
</ParamField>

## Transport

- **Framing:** one JSON-RPC message per line (NDJSON).
- **Direction:** client requests/notifications on stdin; agent responses and `session/update` notifications on stdout.
- **Lifetime:** stdin stays open for the connection; agent exits when the transport closes (stdin EOF). With an injected `stream`, the mode returns instead of calling `process.exit`.
- **Diagnostics:** stderr only. Protocol traffic must never share stdout with logs.

```mermaid
sequenceDiagram
  participant Client as ACP client
  participant Stdio as NDJSON stdio
  participant Acp as runAcpModeWithConnection
  participant Conn as AgentConnection

  Client->>Stdio: initialize
  Stdio->>Acp: request
  Acp-->>Client: protocolVersion, agentCapabilities, agentInfo, _meta

  Client->>Acp: session/new
  Acp->>Conn: subscribe + bind extensions
  Acp-->>Client: sessionId (+ optional cwd _meta)

  Client->>Acp: session/prompt
  Acp->>Conn: promptAndWait + waitForHeadlessCompletion
  Conn-->>Acp: session_event / heartbeats_changed
  Acp-->>Client: session/update (chunks, tools, _meta)
  Acp-->>Client: stopReason

  Client->>Acp: session/cancel (notification)
  Acp->>Conn: abort

  Client->>Acp: session/close
  Acp->>Conn: unsubscribe + abort if needed
  Acp-->>Client: {}
```

## Supported methods

| Method | Kind | Behavior |
|---|---|---|
| `initialize` | request | Returns protocol version, capabilities, agent info, empty namespaced `_meta` |
| `session/new` | request | Creates the single session; binds headless extensions once; subscribes for session lifetime |
| `session/prompt` | request | Runs one turn; streams updates; resolves with `stopReason` or JSON-RPC error on failure |
| `session/cancel` | notification | Aborts only the addressed session’s in-flight turn |
| `session/close` | request | Unsubscribes, aborts in-flight work, frees the single-session slot |

### `initialize`

Returns:

| Field | Value / notes |
|---|---|
| `protocolVersion` | `acp.PROTOCOL_VERSION` from the SDK |
| `agentCapabilities.loadSession` | `false` |
| `agentCapabilities.promptCapabilities` | `{ image: true, embeddedContext: true }` |
| `agentCapabilities.sessionCapabilities` | `{ close: {} }` — client may release the session slot |
| `agentInfo` | `{ name: "prime-agent", title: "Prime Agent", version: VERSION }` |
| `_meta` | `{ "ai.primeintellect.prime-agent": {} }` — namespace advertised; no non-standard keys on the ACP object root |

### `session/new`

<ParamField body="cwd" type="string">
Client-requested working directory. Prime Agent’s cwd is fixed at process startup; a differing `cwd` does not change the agent directory. When the requested path is not the same canonical directory as the real agent cwd, the response includes `_meta.ai.primeintellect.prime-agent.cwd` with `requested` and `actual`.
</ParamField>

<ParamField body="mcpServers" type="array">
Accepted by clients in tests; not a separate ACP session-isolation surface in this mode.
</ParamField>

**Response:** `{ sessionId: string, _meta?: { "ai.primeintellect.prime-agent": { cwd?: { requested, actual } } } }`.

**Cwd comparison:** resolve + `realpathSync` when possible; Windows drive-letter normalization; optional `dev`/`ino` identity when both stats are trustworthy (non-zero). Symlink aliases that resolve to the same directory are not treated as a mismatch.

### `session/prompt`

<ParamField body="sessionId" type="string" required>
Must match the live ACP session.
</ParamField>

<ParamField body="prompt" type="array" required>
Content blocks. Supported types:

| Block `type` | Handling |
|---|---|
| `text` | Joined into the user text (newline-separated) |
| `image` | Requires `data` + `mimeType`; passed as model images |
| `resource` | Embedded `resource.text` (optional `uri` prefix) becomes text context |
| `resource_link` | `uri` string appended as text |

Image and embedded-context blocks are advertised in `initialize` and must reach the model (not dropped silently).
</ParamField>

**Success response:** `{ stopReason: AcpStopReason }`.

**Failure:** JSON-RPC error. Model/provider failures use `prime-agent turn failed: <errorMessage>` rather than a clean `end_turn` with zero updates. Cancellation maps to `{ stopReason: "cancelled" }` (not an error).

Turn outcome uses a pre-turn message boundary (object identity + content key of `role`/`timestamp`/`stopReason`/`errorMessage`) so auto-compaction rebuilds of `state.messages` cannot misattribute an earlier failure or hide this turn’s failure.

### `session/cancel`

Notification. Only cancels when `sessionId` matches **and** a turn abort controller is active. Stray cancels for other session IDs are ignored. Calls `connection.abort()` after marking the local controller aborted.

### `session/close`

Releases subscription, clears the single-session slot, and aborts the underlying agent if a turn is in flight (same abort path as cancel). Post-close activity must not produce further `session/update` notifications. A new `session/new` is then allowed on the same connection. Unknown `sessionId` or a second close of a released id is an error.

## Protocol constraints

| Constraint | Enforcement |
|---|---|
| One ACP session per connection | Second `session/new` throws: *“prime-agent ACP mode hosts one session per connection; start another prime-agent process for a second session”* |
| One prompt turn at a time | Concurrent `session/prompt` throws: *“A prompt turn is already running for this ACP session”* |
| Fixed cwd | Client `cwd` cannot rebind the agent; mismatch reported in `_meta` |
| No concurrent multi-session isolation | Underlying `AgentConnection` would share conversation, cwd, model, and queues if multiple ACP sessions were faked |
| Subscription lifetime | Subscribed for the session (not per turn) so fire-and-forget subagents still stream after the spawning turn |
| Stdout ownership | Non-interactive modes redirect `process.stdout.write` to stderr; ACP frames use the raw stdout escape hatch |
| Client disconnect | Abort in-flight turn, unsubscribe, dispose connection; stdio entrypoint exits `0` |
| Unknown session | Prompt/close for unknown id throws `Unknown ACP session: …` |

Autonomous quality gates run **inside** a single `session/prompt` turn. A failing gate is a continuation, not a stop reason; the request resolves only after `waitForHeadlessCompletion` settles. Gate attempts appear in `_meta` while that runs.

Unsolicited turns (heartbeat/cron, inbound agent messages, out-of-band `session.prompt`) still stream as `session/update` because the subscription is session-scoped.

## Streamed updates (`session/update`)

Agent → client notifications: `{ sessionId, update }`. Mapping is pure in `acpUpdatesForSessionEvent` (one session event → zero or more updates).

### Standard ACP update kinds

| Prime Agent source | `sessionUpdate` | Notes |
|---|---|---|
| Assistant `text_delta` | `agent_message_chunk` | `{ type: "text", text }` content |
| Assistant `thinking_delta` | `agent_thought_chunk` | Separate from visible text |
| `tool_execution_start` | `tool_call` | `status: "in_progress"` |
| `tool_execution_end` | `tool_call_update` | `completed` or `failed`; optional text content |
| `bash_start` | `tool_call` | Synthetic id `prime-agent-bash-<runId>` |
| `bash_output` | `tool_call_update` | Incremental chunk; correlates via mapping state |
| `bash_end` | `tool_call_update` | Failed on non-zero exit or cancel |
| Compaction, subagents, goals, refine, agent messages, heartbeats | `session_info_update` | Namespaced `_meta` only |

Empty deltas and non-assistant `message_update` events emit nothing. Events with no ACP place (for example `agent_start`, `recap_update`) emit nothing.

### Tool kind mapping (`acpToolKind`)

| Tool name | ACP `kind` |
|---|---|
| `ipython` | `execute` (title `"IPython cell"`, `rawInput: { code }`) |
| `bash` | `execute` |
| `read` | `read` |
| `edit`, `write` | `edit` |
| other | `other` |

Tool statuses: `pending` \| `in_progress` \| `completed` \| `failed`.

IPython is the model-facing tool. Cell source is the ACP `rawInput`. Rich kernel output (attachments, diffs under tool `details`) is **not** invented as a MIME bundle: text goes in standard content when present; attachment mime/path/decoded `bytes` and `diffCount` go under `_meta…ipython`. Base64 payloads are not duplicated into `_meta`.

Bash is outside the normal tool-call lifecycle. Mapping state tracks `activeBashRunId` so `bash_output` (no `runId`) attaches to the correct synthetic tool call.

### Heartbeats

Connection-level `heartbeats_changed` (not a session event) becomes:

```json
{
  "sessionUpdate": "session_info_update",
  "_meta": {
    "ai.primeintellect.prime-agent": {
      "heartbeatsChanged": true
    }
  }
}
```

## `_meta` namespace

**Namespace constant:** `PRIME_AGENT_META_NAMESPACE = "ai.primeintellect.prime-agent"`.

**Rule:** never put non-standard fields on ACP object roots; only under `_meta[namespace]`. Vanilla clients ignore `_meta`; Prime Agent-aware clients and harnesses read it.

```json
{
  "sessionUpdate": "session_info_update",
  "_meta": {
    "ai.primeintellect.prime-agent": {
      "subagents": [
        { "id": "sub-1", "sessionName": "reviewer", "status": "running" }
      ]
    }
  }
}
```

### Session meta fields

| Field | Shape | Emitted when |
|---|---|---|
| `cwd` | `{ requested, actual }` | `session/new` cwd mismatch |
| `heartbeatsChanged` | `boolean` | Heartbeat/cron schedule change |
| `goal` | `{ status, objective?, tokenBudget?, tokensUsed? }` | `goal_update` |
| `refinement` | `{ status: "complete"\|"failed", summary?, changes?, error? }` | `refine_complete` / `refine_failed` (`changes` lists applied edits only as `"action kind:id"`) |
| `agentMessage` | `{ toolCallId, target?, deliveryStatus? }` | Kernel sent agent-to-agent message |
| `sessionId` | `string` | Available on meta type (session-level use) |
| `rlmDepth` / `rlmMaxDepth` | numbers | Available on meta type |
| `compaction` | `{ tokensBefore?, summary? }` | `compaction_end` |
| `subagents` | array of `{ id, sessionName?, status, model?, depth?, tokenCount?, error? }` | `rlm_child_update` |
| `autonomous` | `{ enabled, continuationsUsed, turnsUsed, tokensUsed, gateAttempt?, gateFailure?, limitReason? }` | After headless completion of a prompt when autonomous mode is enabled |
| `ipython` | `{ attachments?: [{ mimeType?, path?, bytes? }], diffCount? }` | IPython tool end with rich `details` |

Helper: `primeAgentMeta(payload)` → `{ [namespace]: payload }`.

## Stop reasons

Type: `"end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled"`.

Mapper: `acpStopReason({ cancelled, autonomous? })`.

| Condition | `stopReason` |
|---|---|
| Local abort / `session/cancel` (and cancelled catch path) | `cancelled` (wins over autonomous state) |
| Autonomous disabled or no limit hit | `end_turn` |
| Autonomous `maxTokens` exhausted | `max_tokens` |
| Autonomous `maxContinuations`, `maxTurns`, or `timeoutMs` | `max_turn_requests` |
| (Type includes `refusal`; current mapper does not emit it) | — |

Autonomous default limits (core defaults, used when autonomous mode is configured): `maxContinuations: 3`, `maxTurns: 12`, `maxTokens: 80_000`, plus wall-clock `timeoutMs`. A stopped autonomous run must not be reported as a clean `end_turn` when a limit was hit.

Provider/model failures are **not** stop reasons: they reject the prompt request with an error payload that should include the underlying message (for example provider `401` text in cold-CLI coverage).

## Kernel and feature surface over ACP

ACP is a front end over the same session/kernel stack. Capabilities verified to remain representable:

| Capability | How it appears over ACP |
|---|---|
| IPython state across cells | Each cell → `tool_call` kind `execute` + `tool_call_update` with stdout text |
| Continual harness CRUD (`rlm.harness.*`) | Kernel still runs create/list/get/delete; refine outcomes → `_meta.refinement` |
| RLM depth / subagent list APIs | Kernel env and host handlers; lifecycle → `_meta.subagents` |
| Agent-to-agent messaging (`agent_message` skill) | Send receipt → `_meta.agentMessage`; inbound prompts still stream as message chunks |
| Autonomous gates | Loop inside one prompt; state → `_meta.autonomous` |
| Compaction | `_meta.compaction` (`session_info_update`) |
| Goals | `_meta.goal` |
| Out-of-band turns | Streamed without a client-initiated prompt |

## Error and recovery matrix

| Situation | Client observation |
|---|---|
| Unknown `sessionId` on prompt/close | JSON-RPC error |
| Second concurrent prompt | JSON-RPC error; first turn remains cancellable |
| Second `session/new` without close | JSON-RPC error |
| Cwd differs from agent cwd | Session still created; `_meta…cwd` present |
| Provider/auth/model failure this turn | Prompt rejects; not `end_turn` |
| Earlier failed turn still in transcript | Later turns use turn-boundary membership; slash/handled turns without a new assistant message still `end_turn` |
| Mid-turn compaction rebuild | Failure still detected via identity/key boundary, not pre-turn message count |
| Cancel wrong session | No-op; live turn continues |
| Close mid-turn | Underlying `requestAbort` / connection abort; slot freed |
| Client disconnect (stdin EOF) | Mode tears down; no further updates |

## Minimal client handshake

```json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}
{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/path/to/project","mcpServers":[]}}
{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"<uuid>","prompt":[{"type":"text","text":"say hi"}]}}
```

Expected successful prompt result shape:

```json
{ "stopReason": "end_turn" }
```

Streamed text appears as intermediate `session/update` notifications with `update.sessionUpdate === "agent_message_chunk"`.

## When to use ACP vs other modes

| Mode | Fit |
|---|---|
| **ACP** | External client drives interactive turns (editors, evaluation harnesses); streaming tools + cancel |
| **JSON event stream** | Batch run: dump events and exit code |
| **RPC** | Prime Agent’s richer native command surface (not ACP-compatible) |

## Related pages

<CardGroup>
  <Card title="Use ACP mode" href="/acp-mode">
    Cold CLI launch, feature flags, stop reasons, and cwd constraints for editor integrations.
  </Card>
  <Card title="Agent connection modes" href="/agent-connection">
    In-process connection used by ACP, plus daemon and snapshot paths.
  </Card>
  <Card title="Sessions and runtime" href="/sessions-runtime">
    Session lifecycle, runtime events, and session-scoped state that ACP maps outward.
  </Card>
  <Card title="RLM control plane" href="/rlm-control-plane">
    IPython control tool and `rlm(...)` subagents that surface through ACP `_meta`.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    Provider 401s, auth recovery, and connection-mode failure probes that show up as prompt errors.
  </Card>
</CardGroup>
