---
title: "Hoplite ACP endpoint"
description: "Build ACP clients on persistent Hoplite threads with streamed responses and run control."
canonical_url: "https://hoplite.sh/docs/cli/acp"
markdown_url: "https://hoplite.sh/docs/cli/acp.md"
---

# Hoplite ACP endpoint
URL: /docs/cli/acp
LLM index: /llms.txt
Description: Build ACP clients on persistent Hoplite threads with streamed responses and run control.
Related: /docs/cli, /docs/cli/mcp-server, /docs/threads, /docs/workspace/security

# Hoplite ACP endpoint

Hoplite exposes a public [Agent Client Protocol](https://agentclientprotocol.com/protocol/v1/overview) endpoint. ACP clients can create or load a Hoplite thread, send prompts, receive streamed agent messages, and cancel the active run.

The endpoint negotiates stable ACP v1 or experimental ACP v2 over Streamable HTTP and WebSocket:

```text
https://api.hoplite.sh/acp
wss://api.hoplite.sh/acp
```

Add the project when creating a new session:

```text
https://api.hoplite.sh/acp?projectId=<project-id>
```

Streamable HTTP clients receive `Acp-Connection-Id` during initialization and must send it on later `POST`, `GET`, and `DELETE` requests. Server messages arrive through `GET` requests that accept `text/event-stream`; session streams also use `Acp-Session-Id`. WebSocket clients receive the connection ID in the `101 Switching Protocols` response and exchange JSON-RPC as text frames on the persistent socket. The official TypeScript SDK manages both transports.

Clients that start ACP agents as local processes, such as [Paseo](https://paseo.sh) and [Zed](https://zed.dev), use Hoplite through the CLI instead. See [Local ACP clients](#local-acp-clients).

## Local ACP clients

`hoplite acp` runs the Hoplite CLI as a local ACP agent. It speaks ACP JSON-RPC on stdin and stdout and relays every message to this endpoint. It connects with your stored Hoplite CLI key, so the client needs no token. Each session runs in the Hoplite project linked to the repository at its `cwd`. Pass `--project <project-id>`, or set `HOPLITE_PROJECT_ID`, to use one project for every session.

The `hoplite` command must be on the `PATH` the client uses; otherwise, give its absolute path.

### Signing in

You don't need a separate sign-in step. If you aren't signed in, the first session opens your browser on Hoplite's sign-in page, waits while you approve it, and then continues. Hoplite stores a CLI key for each of your workspaces, in the macOS Keychain where one is available and otherwise in `~/.config/hoplite/credentials.json`. The interactive CLI and `hoplite acp` share these keys. If you start several agents at once, only one browser window opens and the other agents wait for it. The browser URL and code are also written to stderr, in case the browser doesn't open.

If Hoplite later rejects the key because it expired, was revoked, or was stored by CLI 2.x (whose keys lack the permissions sessions need), the next session signs you in again. `hoplite login` signs in on request, and `hoplite logout` revokes and removes the keys stored for this API.

- **Zed** shows a **Sign in to Hoplite** button when a session needs a sign-in (the `hoplite-login` authentication method).
- **Paseo** checks each provider in the background with the `NO_BROWSER` variable set, and creates agents only after that check passes. For that check, `hoplite acp` starts the sign-in in a separate process and waits up to 90 seconds. If you approve it in the browser within that time, the provider becomes available. If you don't, the check fails with "finish signing in in the browser window that opened", and the next check passes once you've signed in. After a sign-in that doesn't complete, the background check won't open another window for 15 minutes. Run `hoplite login` to sign in sooner.

Setting `HOPLITE_ACP_URL` and `HOPLITE_ACP_API_KEY` uses that endpoint and key directly and never signs in.

### Paseo

Add a provider under `agents.providers` in `~/.paseo/config.json`:

```json
{
  "agents": {
    "providers": {
      "hoplite": {
        "extends": "acp",
        "label": "Hoplite",
        "command": ["hoplite", "acp"]
      }
    }
  }
}
```

Paseo checks a provider by opening a session in your home directory. That directory is not a repository, so the check reports `No Hoplite project` unless you pin one. Agents you start in a linked repository still work. To pin a project, use `"command": ["hoplite", "acp", "--project", "<project-id>"]`. Environment variables for the agent go in the provider's `env` object.

Paseo 0.9 runs only local stdio ACP agents. It cannot connect to the `wss://` endpoint directly.

### Zed

Add an agent server to Zed's `settings.json`:

```json
{
  "agent_servers": {
    "Hoplite": {
      "type": "custom",
      "command": "hoplite",
      "args": ["acp"],
      "env": {}
    }
  }
}
```

### What the relay changes

JSON-RPC ids, `session/request_permission`, and the `_hoplite/*` extensions on this page pass through unchanged. The relay changes only this:

- `initialize` offers one authentication method, `hoplite-login`, which signs in through the browser when needed. The CLI authenticates its own connection to Hoplite, so other `authenticate` calls are forwarded with the CLI key's method. Before you sign in, the CLI answers `initialize` itself, with the same capabilities and ACP version 1.
- Client-provided `mcpServers` are dropped, with a notice on stderr. Hoplite runs the MCP servers configured on the project.
- A session in another repository gets its own connection to that repository's project.
- If Hoplite drops the connection, the CLI reconnects and reopens the sessions without replaying them. A prompt that was running when the connection dropped follows its run on the new connection: text the client already has is not sent again, a tool call it already has arrives as a `tool_call_update` rather than a second `tool_call`, and the run's approvals and questions come to the client. The prompt is answered with the stop reason its run ends with. If the run ended while the connection was down, the prompt is answered as soon as the session reopens (see `activeRun` under [Session details](#session-details)). If Hoplite no longer has the run, the prompt fails with an error.
- A request over Hoplite's 1 MiB request limit (see [Connection limits](#connection-limits)) is not sent: the CLI answers it at once with `-32600` ("Request is too large for Hoplite (about 1 MiB max)").
- Closing stdin, `SIGTERM`, or `SIGINT` closes the connections.

Diagnostics go to stderr. Set `HOPLITE_TRACE_LOG=<file>` to write a trace. `HOPLITE_BASE_URL` points the CLI at another Hoplite API.

## Authentication

Every transport request accepts either OAuth or a Hoplite API key. The ACP `initialize` response advertises the matching native authentication method. ACP v1 clients complete protocol login with `authenticate`; ACP v2 clients use `auth/login`. Both versions require protocol login before session methods are available.

### OAuth

OAuth clients discover the resource and authorization server from:

```text
https://api.hoplite.sh/.well-known/oauth-protected-resource/acp
https://api.hoplite.sh/.well-known/oauth-authorization-server/api/auth
```

The protected resource is `https://api.hoplite.sh/acp`. Hoplite supports authorization-code and refresh-token grants, PKCE-capable clients, and dynamic client registration at `/api/auth/oauth2/register`. Request only the scopes the client needs:

| Scope | Allows |
| --- | --- |
| `project:read` | Validate the project selected for a new session |
| `thread:create` | Create a Hoplite thread on the first prompt |
| `thread:read` | Load a thread and replay its messages |
| `thread:update` | Add prompts and cancel an active run |

Send the access token on every HTTP request or the WebSocket upgrade:

```http
Authorization: Bearer <oauth-access-token>
```

After `initialize`, call the native ACP method for the negotiated version:

```json
{"jsonrpc":"2.0","id":2,"method":"authenticate","params":{"methodId":"hoplite-oauth"}}
{"jsonrpc":"2.0","id":2,"method":"auth/login","params":{"methodId":"hoplite-oauth"}}
```

Hoplite re-checks an open connection's credential about once a minute, so an open stream or WebSocket ends when its access token expires (after an hour). To keep the connection, refresh the token before it expires and hand Hoplite the new one. Over HTTP, send the new token on your next request. Any request on the connection, including a new `GET` stream, replaces the credential. Over a WebSocket, send the `_hoplite/auth/refresh` extension request, which `initialize` advertises as `_meta.hoplite.authRefresh`:

```json
{"jsonrpc":"2.0","id":7,"method":"_hoplite/auth/refresh","params":{"token":"<new-oauth-access-token>"}}
```

The new token must belong to the same user and OAuth session as the connection. If it does, the result is `{}`. If it does not, the request fails with `data.code: "auth_required"` and the connection keeps its current credential. This request is the only place a credential appears in a JSON-RPC body. A connection that Hoplite cannot re-check for about three minutes also ends, for example when its key is rate limited or authentication is unavailable.

### API keys

Create an organization-scoped key under **Settings → Workspace → API keys** with project read and thread create, read, update, and stop permissions. Compacting a thread or retrying a run also needs thread retry, and merging a pull request or changing its review loop needs repository update, as in the web app. Send it in either supported form:

```http
X-Api-Key: hop_...
```

```http
Authorization: Bearer hop_...
```

After `initialize`, authenticate the ACP connection with `methodId: "hoplite-api-key"` using the version's login method. Credentials remain transport headers and never appear in JSON-RPC bodies, except in an OAuth token refresh (`_hoplite/auth/refresh`).

## Sessions and prompts

`session/new` validates `projectId` and returns a server-generated session ID. The first `session/prompt` creates a Hoplite thread with that ID and starts its run. Later prompts append to the same durable thread. Reconnect by initializing a fresh transport and calling v1 `session/load` or v2 `session/resume` with the saved session ID. Hoplite authorizes the thread and can replay user and assistant messages through `session/update`.

`session/load` and `session/resume` open any thread you can open in the web app: every thread in your workspace, including teammates' threads you have not joined, as long as it belongs to the project in the endpoint URL. Threads in other workspaces or other projects fail with `-32004` ("Session not found."). Your first prompt in a teammate's thread adds you to its participants, as sending does in the web app. See [thread visibility](/docs/workspace/security#thread-visibility) for organization-wide read access and the narrower scope of delegated agent-run tokens.

Hoplite supports:

| ACP method | Version | Behavior |
| --- | --- | --- |
| `initialize` | v1, v2 | Negotiates capabilities and the active authentication method |
| `authenticate` / `logout` | v1 | Starts or clears native authentication for the transport credential |
| `auth/login` / `auth/logout` | v2 | Starts or clears native authentication for the transport credential |
| `session/new` | v1, v2 | Allocates a session for the selected Hoplite project |
| `session/load` | v1 | Loads an authorized Hoplite thread and replays messages |
| `session/list` | v2 | Lists authorized, non-archived threads for the selected project |
| `session/resume` | v2 | Resumes a thread and optionally replays its messages |
| `session/close` | v2 | Closes the connection's active session state without deleting the thread |
| `session/set_config_option` | v1, v2 | Picks the model, reasoning level, or speed for the session's next prompts |
| `session/prompt` | v1, v2 | Creates or continues the thread and streams the run's text, reasoning, and tool calls |
| `session/cancel` | v1, v2 | Cancels the prompt and stops the active Hoplite run |

Both versions support text blocks, resource-link blocks, and embedded text resources. Hoplite stores each embedded text resource as an attachment on the message, as it stores a file you attach in the web app (see [Attachments](#attachments)). Client-provided MCP servers, images, audio, and embedded binary resources are rejected. Configure MCP servers on the Hoplite project instead.

### Session details

`session/new`, v1 `session/load` and v2 `session/resume` return `_meta.hoplite` with what a client needs for its header:

| Field | Value |
| --- | --- |
| `threadUrl` | The thread's page in the Hoplite web app, `https://app.hoplite.sh/threads/<session-id>`. After `session/new`, the page exists once the first prompt has created the thread |
| `project` | `{ id, name, repo?, defaultBranch? }`: the thread's project, its first repository as `owner/name`, and its base branch |
| `workspace` | `{ name }`: the workspace name. OAuth connections only; API-key connections omit it |
| `activeRun` | v1 `session/load` only. `{ runId, status }` for the thread's live run (`queued`, `running` or `waiting`), which then streams on the session (see [Following a run after a reconnect](#following-a-run-after-a-reconnect)), or `null` when no run is live |
| `lastRun` | v1 `session/load` only, when `activeRun` is `null`. `{ runId, status }` for the thread's latest run that ended: `completed`, `failed` or `cancelled`. Absent when the thread has no runs |
| `presence` | v1 only. Who has the thread open as it opens, in the shape of a heartbeat's result (see [Presence](#presence)). Empty after `session/new`, since the thread does not exist yet |

```json
{
  "_meta": {
    "hoplite": {
      "threadUrl": "https://app.hoplite.sh/threads/2f7c...",
      "project": { "id": "proj_1", "name": "Web", "repo": "acme/web", "defaultBranch": "main" },
      "workspace": { "name": "Acme" }
    }
  }
}
```

Fields Hoplite cannot resolve are left out. A v1 `session/load` with a `replayLimit` also carries `history` in the same object. If Hoplite cannot read the thread's runs, `activeRun` and `lastRun` are both left out.

### Model settings

`session/new`, v1 `session/load` and v2 `session/resume` return `configOptions` with the model, reasoning and speed a prompt on the session runs with. For an existing thread they match the web app composer's pickers when you open the thread: the thread's model, and the reasoning and speed of the newest message that was sent with a model. A pick you made but have not sent yet (see below) takes their place. Anything still unset comes from the project's defaults, otherwise Hoplite's. Each option is a `select`:

| Option ID | Category | Current value |
| --- | --- | --- |
| `model` | `model` | The model ID, such as `moonshotai/kimi-k3`. Each option value carries `_meta.hoplite.supportsFast`, true for a model with a fast (priority) tier, so a picker can mark the models `speed` can be `fast` on. A thread still on a retired model shows it here, but the option list leaves it out, so it can't be picked again once you switch away |
| `reasoning` | `thought_level` | The reasoning level the model runs at. `_meta.hoplite.defaultValue` is the level it uses when none is configured |
| `speed` | `model_config` | `standard` or `fast` (priority processing) |

When a prompt's run uses different settings than the client was last sent, for example after the thread's model was changed in the web app, Hoplite sends a `config_option_update` before the run's activity.

To change a setting, call `session/set_config_option` with the option ID and one of its option values. Like the model and effort pickers in the web app's composer, the choice applies to the session's next prompts: each later `session/prompt` sends the session's settings with its message, and a new session's first prompt creates the thread with them. The model must be one the workspace's plan offers (the `model` option's list); the reasoning level and speed must be ones the selected model supports.

On an existing thread Hoplite also saves the pick for you right away, so it survives a reconnect, a new connection and the next launch of your client, even if you quit before prompting. The saved pick is yours alone and lasts until a newer message with model settings is sent to the thread, by you or a teammate. Once you send a prompt, the thread's messages carry the settings and the web app shows them. Until then the web app does not show a pick you have not sent. Credentials without a user (workspace API keys) keep picks on the connection only. The response carries the full `configOptions` and `_meta.hoplite`:

```json
{"jsonrpc":"2.0","id":7,"method":"session/set_config_option","params":{"sessionId":"<session-id>","configId":"model","value":"claude-opus-5-5"}}
```

```json
{
  "configOptions": [{ "type": "select", "id": "model", "currentValue": "claude-opus-5-5", "options": [...] }, ...],
  "_meta": {
    "hoplite": {
      "compactsContext": true,
      "modelChange": { "from": "moonshotai/kimi-k3", "to": "claude-opus-5-5" }
    }
  }
}
```

`compactsContext` is `true` when the next prompt changes the model of an existing thread: Hoplite then compacts the previous context before that prompt's run, as it does after a model change in the web app, and `modelChange` names both models. Show a warning so the user can pick the previous model again before sending. A value the session cannot use fails with `-32602`; `data.code` is `invalid_model` for a model the plan does not offer. ACP v2 clients send `configId` and `value` with `type: "id"`.

Hoplite does not return `modes` or the unstable `models` field, and has no `session/set_mode` or `session/set_model`: clients that read `configOptions`, such as Zed and Paseo, pick the model and reasoning level with `session/set_config_option`. Some clients apply their own saved choice to a new session. Paseo, for example, sends the model you last picked for the provider, so a new session can run with that model instead of the project's default. The session's `currentValue` always shows what it runs with.

### Paged history

Long threads can open quickly. When `initialize` returns `_meta.hoplite.sessionHistory`, pass `_meta: { "hoplite": { "replayLimit": 20 } }` to v1 `session/load`. Hoplite then replays only the latest 20 messages (at most 100) and responds with `_meta.hoplite.history`, which holds `hasOlder` and a `cursor`. To fetch older messages, call the `_hoplite/session/history` extension request with `sessionId`, `before` (the cursor) and an optional `limit`. It returns `messages` oldest first, each as `{ role: "user" | "agent", text }`, plus the next `hasOlder` and `cursor`. User entries also carry `author` and `viewer` (see [Message authors](#message-authors)), and `attachments` when the message has any (see [Attachments](#attachments)). Continue until `cursor` is `null`. Without `replayLimit`, `session/load` replays every message.

### Message authors

Threads are shared, so every user message Hoplite sends says who wrote it. Replayed and live `user_message_chunk` updates carry `_meta.hoplite`:

```json
{
  "sessionUpdate": "user_message_chunk",
  "content": { "type": "text", "text": "Can you also run the linter?" },
  "_meta": {
    "hoplite": {
      "messageId": "msg_123",
      "part": { "index": 0, "count": 1 },
      "author": { "id": "user_ada", "name": "Ada Lovelace" },
      "viewer": false,
      "live": true
    }
  }
}
```

`author` is `null` for messages without a user, such as automations; `name` is `null` when the user is no longer in the workspace. `viewer` is `true` when the author is the connection's user: the OAuth user, or the member who created the API key. `live` marks a message pushed while the session is open rather than replayed. `part` says which of the message's chunks this is (see [Attachments](#attachments)). ACP v2 `user_message` updates carry `author` and `viewer` the same way.

### Attachments

A long paste or a file in a prompt becomes an attachment, the same as in the web app. Send it as an embedded text resource. The last segment of the resource's `uri` becomes the file name, and its `mimeType` becomes the content type. If the resource has no `mimeType`, Hoplite uses the file extension. The Hoplite CLI names pastes the way the web app does: `pasted-text.txt`, then `pasted-text-2.txt`, and so on.

```json
{"sessionId":"<session-id>","prompt":[
  {"type":"text","text":"Why does this fail?"},
  {"type":"resource","resource":{"uri":"file:///pasted-text.txt","mimeType":"text/plain","text":"panic: index out of bounds\n..."}}]}
```

Hoplite stores the text in the thread's attachment storage. The stored message is the prompt's text, then a link to each attachment, separated by blank lines, as the web composer writes it:

```text
Why does this fail?

[pasted-text.txt](/api/attachments/0123456789abcdef0123456789abcdef)
```

This works for a session's first prompt, later prompts, and steered and queued prompts. The web app shows each file as an attachment. The agent gets each file in its workspace at `.hoplite/attachments/<artifact-id>/<name>`, listed in its prompt, the same way it gets files attached in the web app.

A prompt can carry at most 10 embedded resources, and each one must be between 1 byte and 50 MB. An empty resource fails with `-32602` and `data.code: "attachment_empty"`. A resource over 50 MB fails with `-32602` and `data.code: "body_too_large"`, and more than 10 resources fail with `-32602` and `data.code: "too_many_attachments"`. The whole ACP request must also fit within the 1 MiB request limit, so in practice that limit is lower (see [Connection limits](#connection-limits)). Uploads need cloud access, as in the web app: without it the prompt fails with the same `-32000` access error that other thread actions return. If the server has no attachment storage, the prompt fails with `data.code: "attachments_unavailable"`. A thread that runs on a local agent cannot take attachments, so there Hoplite adds each resource to the message text as `<uri>`, a blank line, and then the text.

Replays show attachments as `resource_link` content blocks after the message's text. In ACP v1, a user message is one `user_message_chunk` for the text, followed by one `user_message_chunk` for each attachment. Every chunk carries the same `_meta.hoplite` (`messageId`, `author`, `viewer`, and `live` when it applies), plus `part: { index, count }`: the chunk's position in the message, from 0, and the message's number of chunks. The chunk with `index` equal to `count - 1` is the last one, so a client can show the message as soon as that chunk arrives, and a message with `count: 1` right away, without waiting for more chunks. Replayed and live messages both carry `part`. If a message has only attachments, it has no text chunk, and its first attachment has `index: 0`. Earlier Hoplite versions sent no `part`; clients that ignore it still work.

```json
{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Why does this fail?"},"_meta":{"hoplite":{"messageId":"msg_123","part":{"index":0,"count":2},"author":{"id":"user_1","name":"Sam"},"viewer":true}}}
{"sessionUpdate":"user_message_chunk","content":{"type":"resource_link","name":"pasted-text.txt","mimeType":"text/plain","uri":"https://api.hoplite.sh/api/attachments/0123456789abcdef0123456789abcdef"},"_meta":{"hoplite":{"messageId":"msg_123","part":{"index":1,"count":2},"author":{"id":"user_1","name":"Sam"},"viewer":true}}}
```

In ACP v2, a `user_message` update's `content` is the text block followed by the `resource_link` blocks. A v2 message arrives as one update, so it has no `part`. `_hoplite/session/history` entries and queue entries (`_hoplite/queue/list` and `_hoplite/queue_update`) carry the same `resource_link` blocks in an `attachments` array next to `text`, and `text` excludes the links. `_hoplite/queue/update` replaces the text and keeps the message's attachments.

A `resource_link` has `type`, `name`, `mimeType`, and `uri`. `mimeType` comes from the file name's extension, or from the stored type for files that came from Slack. `uri` is the attachment's URL on the Hoplite API. It needs the same credentials as the rest of the API: a session cookie or an API key. Hoplite finds attachments in two places:

- the message's final paragraphs, when each one is a single markdown link to a Hoplite attachment URL (`/api/attachments/<32 hex>` or `/api/threads/<id>/attachments/<artifact-id>`). The link must be relative or on the Hoplite API's own origin (`https://api.hoplite.sh/...`). A link to the same path on any other host stays in the message text, so a message can't pass off another site's file as a Hoplite attachment.
- files attached from Slack.

Links inside the prose stay in the text. Messages from earlier Hoplite versions, which added pastes to the text, keep that inline form (`<text>`, a blank line, `file:///pasted-text.txt`, a blank line, then the pasted text). Hoplite replays those unchanged, as a single text chunk.

## Streamed run activity

While a prompt runs, Hoplite streams the run through `session/update`:

| Run activity | ACP v1 | ACP v2 |
| --- | --- | --- |
| Assistant text as it is written | `agent_message_chunk` | `agent_message_chunk` with a `messageId` |
| Model reasoning as it is written | `agent_thought_chunk`, with `_meta.hoplite.thoughtId` naming the thought it belongs to | `agent_thought` with a `messageId`, carrying the whole thought so far |
| Text the agent wrote before calling tools | Stays in the message it streamed into; the part not yet streamed, or all of it when none streamed, arrives with `_meta.hoplite.narrationOf` (see below) | `agent_message` clears the draft, then `agent_thought` carries it |
| Tool calls and delegated tasks | `tool_call`, then `tool_call_update` | `tool_call_update` upserts |
| Final answer | The part not already streamed, as `agent_message_chunk` | `agent_message` replaces the streamed draft |
| Run failure | The failure reason as `agent_message_chunk` | The failure reason as `agent_message` |

Tool updates carry a title such as `Run pnpm test`, an ACP tool kind, `in_progress`, `pending` (waiting for an approval or an answer), `completed`, or `failed` status, the tool input as `rawInput`, and a redacted output preview. Redaction masks only secret values: known credential formats, private keys, URL passwords, `Authorization`, `X-Api-Key`, `X-Auth` and `Cookie` header values, `--password`-style flags, credential-named assignments such as `password: …` or `DB_PASSWORD=…`, and any value under a credential-named JSON key. Ordinary code such as `const session = await getSession()` arrives unchanged. A tool call on a credential file (see [File edit diffs](#file-edit-diffs)) leaves out `rawInput`, and a read of one also leaves out its output. The web app's tool details and transcript exports show `[withheld: credential file]` in its place. When a tool waits for approval, Hoplite sends `session/request_permission` with Allow and Reject options, and the client's answer approves or rejects the tool in the Hoplite thread. The tool update's text says where it is decided: `Waiting for your approval.` when this client is asked, otherwise `Waiting for approval — answer it in Hoplite: <thread link>`. If the approval is decided elsewhere first (in the web app, or by another client) or its run ends, Hoplite withdraws the request with `$/cancel_request`; the client should take its prompt down, and any answer it sends afterwards is ignored.

A command's line breaks show in the title as ` ↵ `, and the title is cut at 120 characters. When the agent gave the call a short description, the update also carries it in `_meta.hoplite.description`, with the title's argument (the command, path, pattern, query or URL) in `_meta.hoplite.target`, so a client can show both, as the Hoplite CLI does: `Run the API tests · pnpm test`.

A tool's text content is its result as the web app shows it: a command's stdout and stderr, then how it ended (`Exited with code 1`, or `Timed out and was stopped`). Hoplite records a result as a preview of at most 2,048 characters, so a long result is cut: its text then ends with a `[output truncated]` line (before the exit status) and the update carries `_meta.hoplite.outputTruncated: true`. To read the whole result, call the `_hoplite/tool/detail` extension request with `sessionId` and `toolCallId`, which `initialize` advertises as `_meta.hoplite.toolDetail`. It needs the `thread:read` scope and returns the web app's tool details, redacted the same way: `{ toolCallId, status, text, inputText, outputText, errorText }`, where `text` is the whole result read like the content above. A call Hoplite has no record of fails with `-32004`. `rawInput` is the input object; a preview that was cut stays the string it is. `rawOutput` is the result as Hoplite recorded it.

Some updates carry more of what the web app shows in `_meta.hoplite`:

| Field | Meaning |
| --- | --- |
| `presentationKind` | Hoplite's own kind (`list`, `write`, `github`, `todo`, …) when the ACP `kind` loses it, for example a listing sent as `read` or a write as `edit` |
| `question` | `true` on the agent's question tool, even when its input was too long to send whole |
| `failureClass` | On a `failed` call: `rejection` when Hoplite refused it before it ran (a patch whose context did not match, a repeated call; the web app does not show these as failures), `partial_side_effect`, or `operational` |
| `sideEffectState` | On a `failed` call: `none`, `possible`, or `partial`. A `partial` edit (an `apply_patch` whose earlier operations were applied) keeps the diffs of what it changed |

Text and tool calls arrive in the order the agent produced them. When a tool call or approval follows text this client is still receiving, the rest of that text is sent first, so text is not split around the tool.

ACP v1 `agent_message_chunk` has no message ID, so a client appends every chunk of a turn to one message. To keep separate texts apart, such as the text the agent wrote before a tool call and the answer after it, the first chunk of each new text starts with `"\n\n"` when earlier text in the turn did not already end with a blank line. Hoplite never adds it inside a text, and never before a turn's first text. The final answer's missing part continues the text it completes; a final answer that does not continue the streamed text starts a new one. ACP v2 chunks carry a `messageId` and are unchanged.

v1 cannot take back text it streamed, so text the agent wrote before calling tools stays message text when it streamed, and arrives as an `agent_thought_chunk` when none of it did. Both carry `_meta.hoplite.narrationOf`, naming the draft it settles, so a client can show the two cases alike: the Hoplite CLI shows both as the agent's text.

### File edit diffs

Tool calls of kind `edit` also carry the change as ACP `diff` content, next to the output preview. Each ACP v1 entry is one change:

```json
{
  "type": "diff",
  "path": "src/auth.ts",
  "oldText": "  const current = await store.read(id);\n  if (current.expiresAt < Date.now()) {",
  "newText": "  const current = await store.read(id);\n  if (current.expiresAt <= clock.now()) {",
  "_meta": { "hoplite": { "operation": "update", "partial": true } }
}
```

Hoplite derives diffs from the edit tool's input, so `path` is the path the agent wrote, usually relative to the repository root. `_meta.hoplite.operation` says what the text means:

| `operation` | Source | `oldText` | `newText` |
| --- | --- | --- | --- |
| `update` | One `apply_patch` hunk, with its context lines | The hunk's old lines | The hunk's new lines |
| `add` | `apply_patch` Add File | `null` | The new file |
| `replace` | `apply_patch` Delete and Add of the same file | `null` | The new file |
| `delete` | `apply_patch` Delete File | `null` | `""` |
| `edit` | `Edit` or `edit_file`, one entry per replacement | The replaced text | The replacement |
| `write` | `Write` or `write_file` | `null` (the previous content is not recorded) | The written file |

`partial: true` marks a changed region rather than a whole file, and `movedTo` is set when a patch moves the file. A file can have several entries. Diffs appear on the `in_progress`, `pending` and `completed` updates; a `failed` edit changed nothing and carries none.

Each side of a diff is capped at 16,000 characters, cut at a line boundary and marked `truncated: true`. One update carries at most 20 entries and 64,000 characters. Files over the limit, and files that hold credentials, are listed in the tool update's `_meta.hoplite.omittedDiffs` as `{ path, reason: "limit" | "sensitive" }`. Credential files are `.env` files other than templates such as `.env.example`; keys and keystores such as `*.pem`, `*.ppk`, and `id_rsa` or `id_ed25519` and their variants (but not `*.pub`); `credentials.json`, `service-account*.json`, and `secrets.{yaml,yml,json,toml}`; `.dev.vars`, `.git-credentials`, `.pypirc`, `.htpasswd`, `.npmrc`, `.netrc`, and `local.settings.json`; `.docker/config.json`, `.kube/config`, and `kubeconfig`; Terraform `*.tfvars` and `*.tfstate`; and anything under a `secrets` directory. When a credential file is omitted, or when the diffs of an edit can't be derived, the update also leaves out `rawInput`. Text passes through the same redaction as the web app's tool details.

ACP v2 tool updates carry one `diff` entry with structured `changes` (`add`, `delete`, `modify`, or `move`) and the same entries, in the v1 shape, in `_meta.hoplite.diffs`.

### Run phases

Before its model responds, a run can spend time preparing: starting a sandbox, cloning the repository, running the setup script, or waiting for a worker. Hoplite reports these with the `_hoplite/run_phase` extension notification, which standard ACP clients ignore:

```json
{"jsonrpc":"2.0","method":"_hoplite/run_phase","params":{"sessionId":"<session-id>","runId":"run_123","phase":"allocating_sandbox","label":"Starting sandbox","startedAt":"2026-09-24T12:31:11.515Z"}}
```

A notification is sent when the phase changes. `phase`, `label` and `startedAt` are `null` when the phase ends: as soon as the model streams text, a thought, or a tool call, and when the run ends. The phase ends before that output is sent.

| `phase` | `label` | When |
| --- | --- | --- |
| `provisioning_workspace` | Preparing workspace | The thread's workspace is being prepared, before a finer stage is known |
| `resolving_repository` | Finding your repository | Resolving the project's repository |
| `allocating_sandbox` | Starting sandbox | Creating the sandbox |
| `fetching_repository` | Cloning repository | Cloning or fetching the repository |
| `configuring_workspace` | Configuring workspace | Environment and setup script |
| `dispatching_run` | Starting agent | The workspace is ready and the agent is starting |
| `queued` | Waiting to start | The run waits for a worker |
| `compacting` | Compacting context | Context compaction, which can also happen mid-run |

Workspace phases occur while a thread's first run prepares its workspace. Show `label`, and treat unknown `phase` values as a generic preparing state.

## Live thread activity

Threads are shared: teammates can post from the web app, Slack, or another client while your session is open. After ACP v1 `session/load`, and after the first `session/prompt` on a new session, Hoplite keeps the session live and pushes what others add, even when this client has no prompt running:

1. The message, as a `user_message_chunk` with `_meta.hoplite.author` and `live: true`.
2. `_hoplite/run_status` with `status: "running"` when the run it started begins.
3. That run's activity, exactly as for a prompt: text, thoughts, tool calls, diffs, and run phases.
4. Its final answer, then `_hoplite/run_status` with `completed`, `failed` or `cancelled`.

```json
{"jsonrpc":"2.0","method":"_hoplite/run_status","params":{"sessionId":"<session-id>","runId":"run_456","status":"running","author":{"id":"user_ada","name":"Ada Lovelace"},"viewer":false}}
```

A message a teammate sends while your prompt runs, for example one that steers the running turn, arrives once, in order with your run's activity. Your own prompts are never echoed, and runs you prompted produce no `_hoplite/run_status`: the `session/prompt` response ends them. If your prompt joins a run that was announced as someone else's, that run's closing `_hoplite/run_status` follows your prompt's final answer.

Hoplite never sends `session/request_permission` or agent questions for a teammate's run (`viewer: false`). Its approvals stay with the person who started it: the tool update shows `pending`, and the run continues once they decide in Hoplite. A run you started yourself (`viewer: true`) that this connection did not prompt, such as one you started from another client or a run followed after a reconnect, asks its approvals and questions here too, as your own prompt would. The first answer, here or in Hoplite, decides. Approvals already decided before the session opened are not asked again.

### Following a run after a reconnect

If a connection drops while a prompt runs, the run goes on in Hoplite. Its response is lost with the connection. To pick the run up, open a new connection and call v1 `session/load` for the session. A `replayLimit` of `1` keeps the replay short. Then:

1. Check `_meta.hoplite.activeRun` in the result. If it is `null`, the run is over: end the turn at once, with the stop reason for `lastRun.status` (`completed` is `end_turn`, `cancelled` is `cancelled`, `failed` is `refusal`). If `lastRun` is missing too, the run is gone, for example because it was lost when the server restarted. An older Hoplite leaves `activeRun` out; wait for `_hoplite/run_status` then.
2. Otherwise Hoplite follows the run like a teammate's: `_hoplite/run_status` `running`, the run's activity from its start, its final answer, and a closing `_hoplite/run_status`. The client already has the start. Skip the text it already shows, and treat a `tool_call` for a `toolCallId` it already has as an update of that call.

```json
{"jsonrpc":"2.0","id":4,"result":{"_meta":{"hoplite":{"threadUrl":"https://app.hoplite.sh/threads/<session-id>","activeRun":null,"lastRun":{"runId":"run_456","status":"completed"}}}}}
```

On one session, a `session/prompt` response always arrives before any update of a later run. This matters when a queued prompt's run starts as the previous run ends. ACP v1 updates carry no run ID, so a client can end a turn at its response and attribute everything after it to the next run.

Updates arrive on the session's stream, so keep it open after the prompt returns. Hoplite pushes them when the thread changes, usually within half a second, through the same sync service as the web app. If that service is unavailable, Hoplite checks the thread about once a second instead, so activity can lag by up to a second. While the connection has no open stream, Hoplite holds the session's updates and catches up when a stream reopens. Live activity stops when the connection closes or expires. A connection keeps up to 8 sessions live; loading a ninth stops the oldest. ACP v2 sessions do not receive live activity yet.

### Presence

Presence shows who else has a thread open and who is typing in it, like the avatars in the web app's thread header. It is an ACP v1 extension. Hoplite advertises it in the `initialize` result:

```json
{"_meta":{"hoplite":{"presence":{"heartbeatMs":5000,"ttlMs":15000}}}}
```

While the person has a session open, send `_hoplite/presence/heartbeat` every `heartbeatMs`, and at once when their state changes. Use `composing` while their message box holds text, and `viewing` otherwise. A client that stops sending heartbeats drops out after `ttlMs`. Hoplite never marks a client present on its own, because an open connection does not mean anyone is looking.

| Param | Value |
| --- | --- |
| `sessionId` | The session |
| `clientId` | Your ID for this client, 1 to 128 characters, kept for the life of the process. A client is in one session at a time: a heartbeat for another session moves it there. A connection keeps its 4 most recently active client IDs; a fifth removes the oldest one's presence |
| `state` | `viewing` or `composing` |
| `timeZone` | Optional. The client's IANA time zone, such as `Europe/London`. Other clients can show the person's local time |

The result is who has the thread open, one entry per person. A person who is `composing` on any of their clients counts as `composing`. `viewer` marks your own entry, which clients leave out when they show who else is there:

```json
{
  "threadId": "<session-id>",
  "serverTime": "2026-09-24T12:00:05.000Z",
  "participants": [
    { "user": { "id": "user_ada", "name": "Ada Lovelace", "image": "https://..." }, "state": "composing", "clientCount": 1, "lastSeenAt": "2026-09-24T12:00:03.000Z", "timeZone": "Europe/London", "viewer": false },
    { "user": { "id": "user_1", "name": "Sam Rivera" }, "state": "viewing", "clientCount": 2, "lastSeenAt": "2026-09-24T12:00:05.000Z", "timeZone": null, "viewer": true }
  ]
}
```

After your first heartbeat on a session, Hoplite sends `_hoplite/presence_update` on the session's stream whenever someone joins, leaves, drops out, or switches between viewing and composing. The params are `{ sessionId, serverTime, participants }`. Heartbeats alone, which only move `lastSeenAt`, send nothing. Pushes need the session to be live on the connection (loaded, or prompted once); each heartbeat result carries the same list either way. Pushes and results can arrive out of order, so keep the one with the latest `serverTime`.

Send `_hoplite/presence/leave` with `{ sessionId, clientId }` when the person leaves the session, for example when they switch threads or quit. It returns `{ "ok": true }`. Closing the connection with `DELETE` removes its clients from every thread as well.

Heartbeats need a user: an API key that belongs to no user fails with `-32001` and `data.code` `user_identity_required`. A thread you cannot open fails with `-32004`. Before a new session's first prompt, the heartbeat succeeds with no participants, since the thread does not exist yet.

The Hoplite CLI shows presence in its footer, for example `Ada composing`, `Ada, Bo viewing`, or `3 viewing`. The segment is the first to go when the terminal is narrow. `hoplite acp` passes presence through, and it does not repeat a push that only moves the clocks after it reopens a session.

## Conversation controls

These ACP v1 features match the web app's thread view. Each uses the same Hoplite action, validation, and permissions as the web app. Extension requests fail with a JSON-RPC error whose `data.code` is the web app's error code: `-32602` for invalid params, `-32001` when the credential lacks a permission (`data.permission` names it), `-32004` for a missing session or message, and `-32000` for other refusals such as `thread_has_active_run`. Refusals caused by billing, such as `subscription_required`, also carry `data.billingUrl`.

### Agent questions

When the agent asks the user a question (the web app's "needs your input" card), Hoplite sends the prompting client a standard ACP form elicitation. Advertise it in `initialize` with `clientCapabilities: { "elicitation": { "form": {} } }`. A client that does not advertise form elicitation is asked with `session/request_permission` instead (see [Questions without elicitation](#questions-without-elicitation)). While the question is open, the question's tool update is `pending` and its text is `Waiting for your answer.`; when it was not sent to this client (a teammate's run, or an ACP v2 session), the text is `Waiting for an answer in Hoplite: <thread link>`.

```json
{
  "jsonrpc": "2.0",
  "id": 12,
  "method": "elicitation/create",
  "params": {
    "sessionId": "<session-id>",
    "toolCallId": "call_ask",
    "mode": "form",
    "message": "Which database should the session store use?",
    "requestedSchema": {
      "type": "object",
      "properties": {
        "q1": {
          "type": "string",
          "title": "Database",
          "description": "Which database should the session store use?",
          "oneOf": [
            { "const": "Postgres", "title": "Postgres", "description": "The app's existing database" },
            { "const": "SQLite", "title": "SQLite", "description": "A local file per instance" }
          ]
        }
      },
      "required": ["q1"]
    },
    "_meta": {
      "hoplite": {
        "approvalId": "appr_123",
        "questions": [
          {
            "id": "q1",
            "header": "Database",
            "prompt": "Which database should the session store use?",
            "multiSelect": false,
            "allowOther": true,
            "options": [
              { "label": "Postgres", "description": "The app's existing database", "preview": "sessions (id text primary key, ...)" },
              { "label": "SQLite", "description": "A local file per instance" }
            ]
          }
        ]
      }
    }
  }
}
```

A request asks up to four questions, `q1` to `q4`. A single-select question is a `string` property with `oneOf`; a multi-select question is an `array` property whose `items.anyOf` lists the options. `_meta.hoplite.questions` carries the same questions with each option's description and optional `preview`, for clients that render their own form. Every question also accepts free text, like the web app's "Other" field.

Answer with `accept` and a `content` value for every question: an option label, free text, or, for multi-select, an array of labels (Hoplite joins them with `, `, as the web app does):

```json
{"jsonrpc":"2.0","id":12,"result":{"action":"accept","content":{"q1":"Postgres"}}}
```

`decline` or `cancel` rejects the question, like the web app's Cancel, and the agent continues without an answer. So does an `accept` that leaves a question unanswered. As in the web app, an unanswered question is never declined for waiting: it stays open until someone answers it. If it is answered in Hoplite first, or its run or the prompt ends, Hoplite withdraws the request with `$/cancel_request` and the question stays with Hoplite. Like approvals, questions from a teammate's run are never sent to it.

#### Questions without elicitation

A client without form elicitation, such as Paseo, gets each question as a `session/request_permission`, one after another. Every choice is an `allow_once` option, and `Skip` (`reject_once`) declines:

```json
{
  "jsonrpc": "2.0",
  "id": 12,
  "method": "session/request_permission",
  "params": {
    "sessionId": "<session-id>",
    "toolCall": {
      "toolCallId": "call_ask",
      "title": "Database: Which database should the session store use?",
      "content": [{ "type": "content", "content": { "type": "text", "text": "Which database should the session store use?\n\nPostgres: The app's existing database\nSQLite: A local file per instance\n\nTo write your own answer, answer in Hoplite: https://app.hoplite.sh/threads/<session-id>\nSkip declines the question." } }],
      "_meta": { "hoplite": { "approvalId": "appr_123", "question": { "index": 0, "header": "Database", "prompt": "Which database should the session store use?", "multiSelect": false } } }
    },
    "options": [
      { "optionId": "option_1", "name": "Postgres", "kind": "allow_once" },
      { "optionId": "option_2", "name": "SQLite", "kind": "allow_once" },
      { "optionId": "skip", "name": "Skip", "kind": "reject_once" }
    ]
  }
}
```

Choosing an option answers that question with the option's label. When every question is answered, Hoplite submits the answers together, as the web app does. `Skip` on any question declines the whole request, like the web app's Cancel. A `cancelled` outcome leaves the question open in Hoplite, and the 10-minute limit applies as for elicitation. The title counts questions when there are several, for example `(1/2)`.

A permission request cannot express everything a question can, so two things change:

- A multi-select question takes one choice. To choose several, answer in Hoplite.
- There is no free-text ("Other") answer. To write one, answer in Hoplite. The request's text links to the thread.

Paseo shows the request as a chooser, with the text as its body and one button per option. Because it has more than one allow option, Paseo's Auto Accept never answers it.

### Steering and queueing

While a prompt of the session is running, a second `session/prompt` must say how to deliver its message with `_meta.hoplite.delivery`, the choice the web app's composer offers while the agent works. Without it the request fails with `data.code: "prompt_in_flight"`.

```json
{"jsonrpc":"2.0","id":9,"method":"session/prompt","params":{"sessionId":"<session-id>","prompt":[{"type":"text","text":"Then deploy to staging."}],"_meta":{"hoplite":{"delivery":"queue"}}}}
```

| `delivery` | Behavior | Response |
| --- | --- | --- |
| `steer` | Joins the running turn, which reads it at its next step. Its activity keeps streaming on the prompt already relaying that run | When that run ends, with its stop reason and `_meta.hoplite: { delivery: "steer", messageId, runId }` |
| `queue` | Waits until the running turn ends, then starts its own run, streamed on this prompt like any other, approvals and questions included | When its own run ends, with `_meta.hoplite: { delivery: "queue", messageId, runId }` |

Any v1 `session/prompt` may also carry `_meta.hoplite.clientMessageId`, an ID the client generates for the prompt: 8 to 64 letters, digits, `_` or `-` (a UUID fits). The message's ID is derived from it in the sender's own namespace (`acp_<hash>_<clientMessageId>`), so it never names another member's message, and a retry by the same member with the same ID in the same thread returns the stored message instead of sending it twice. Hoplite echoes it as `_meta.hoplite.clientMessageId` on the prompt's response and as `clientMessageId` on its queue entries, so a client can find its own queued prompts without matching text. IDs that start with prefixes Hoplite's integrations use, such as `agent_`, fail with `data.code: "reserved_message_id"`, and malformed IDs fail with `-32602`.

```json
{"jsonrpc":"2.0","id":9,"method":"session/prompt","params":{"sessionId":"<session-id>","prompt":[{"type":"text","text":"Then deploy to staging."}],"_meta":{"hoplite":{"delivery":"queue","clientMessageId":"cli_5f2c9a"}}}}
```

A run that cannot take steering, such as a compaction, queues the message instead. A first prompt may also set `delivery`, for example to queue behind a teammate's run. A queued prompt whose message is deleted before its run starts ends with `stopReason: "cancelled"` and `_meta.hoplite.queue: "removed"`. `session/cancel` stops the running turn, as the web app's Stop does, and the queue then starts at once: queued prompts keep waiting and run next. To drop one, delete its message. The exception is a first prompt that is itself still queued (behind a teammate's run, say): `session/cancel` ends it and removes its message.

The queue is shared by everyone in the thread. Hoplite pushes it with `_hoplite/queue_update` whenever it changes, and `_hoplite/queue/list` returns it. Messages are oldest first:

```json
{"jsonrpc":"2.0","method":"_hoplite/queue_update","params":{"sessionId":"<session-id>","messages":[{"messageId":"acp_3f9d0c2b7a1e4d58_cli_5f2c9a","clientMessageId":"cli_5f2c9a","text":"Then deploy to staging.","author":{"id":"user_1","name":"Ada Lovelace"},"viewer":true,"createdAt":"2026-09-24T12:31:11.515Z","queuedAfterRunId":"run_7"}]}}
```

`clientMessageId` is `null` for messages sent without one, such as a teammate's from the web app. An entry whose message has attachments also carries `attachments`, and its `text` leaves out their links (see [Attachments](#attachments)). `_hoplite/queue/list` returns entries of the same shape.

| Request | Params | Result |
| --- | --- | --- |
| `_hoplite/queue/list` | `{ sessionId }` | `{ messages }` |
| `_hoplite/queue/update` | `{ sessionId, messageId, text }` | `{}`. Edits a queued message's text and keeps its attachments. `-32004` (`message_not_queued`) once its run has started |
| `_hoplite/queue/delete` | `{ sessionId, messageId }` | `{}`. Removes a queued message |
| `_hoplite/queue/run_now` | `{ sessionId }` | `{ stoppedRunId }`. Interrupts the running turn so the queue starts now, like the web app's "Interrupt and run now". Needs thread stop |

### Thread list

`_hoplite/session/list` lists the project's threads for a resume picker, from the same list and search as the web app:

```json
{"jsonrpc":"2.0","id":5,"method":"_hoplite/session/list","params":{"scope":"team","query":"flaky","limit":20}}
```

| Param | Value |
| --- | --- |
| `scope` | `mine` (default): threads you created, joined, or wrote in, as the web app's Mine filter. `team`: every thread in the project |
| `query` | Optional. Searches thread titles and message text, as the web app's search does, and returns up to 20 results without a cursor |
| `status` | Optional thread status, or `archived`. Archived threads are left out otherwise |
| `cwd` | Optional. Only sessions created with this `cwd`. A thread reports the `cwd` it was created with only to its author; threads created elsewhere, and other people's threads, report `/workspace` |
| `cursor`, `limit` | Paging: pass the previous `nextCursor`; `limit` is 1 to 100 (default 50) |

```json
{
  "sessions": [
    {
      "sessionId": "thr_2",
      "title": "Flaky auth test",
      "status": "ready",
      "updatedAt": "2026-09-24T12:31:11.515Z",
      "cwd": "/workspace",
      "author": { "id": "user_2", "name": null },
      "viewer": false,
      "joined": false,
      "repo": "acme/web",
      "branch": "fix-auth",
      "prNumber": 12,
      "match": { "excerpt": "the flaky test", "messageId": "msg_9", "role": "user" }
    }
  ],
  "nextCursor": null
}
```

`author` is the thread's creator (`name` is not resolved in lists) and `viewer` says whether that is you. `repo`, `branch` and `prNumber` come from the thread's pull request and are absent without one. `match` appears only on search results. `joined` says whether the thread is in your Mine scope (you created it, joined it, or wrote in it). It is informational: `session/load` opens any listed thread whether or not you joined it. To follow a teammate's thread without writing in it, call `_hoplite/session/join` with `{ sessionId }`. It adds you to the thread's participants, like the web app's Follow, and returns `{ "joined": true }`. `_hoplite/session/leave` with `{ sessionId }` undoes a follow and returns `{ "left": true }`. It only removes a follow: a thread you created or wrote in stays in your Mine scope, and the result is `{ "left": false, "reason": "creator" }` or `"contributor"` (`"not_following"` when you didn't follow it). The list needs `project:read` and `thread:read`; joining needs `thread:update`. API keys without a user have no Mine scope and fail with `user_identity_required`.

### Titles and rename

After `session/load` and whenever the title changes (Hoplite's automatic title after the first turn, or a rename in the web app), Hoplite sends a standard `session_info_update`:

```json
{"sessionId":"<session-id>","update":{"sessionUpdate":"session_info_update","title":"Fix the flaky auth test","updatedAt":"2026-09-24T12:31:11.515Z"}}
```

`_hoplite/session/rename` renames the thread, as the web app's inline rename does:

```json
{"jsonrpc":"2.0","id":6,"method":"_hoplite/session/rename","params":{"sessionId":"<session-id>","title":"Session store","expectedTitleVersion":3}}
```

The title is trimmed and must not be empty. `expectedTitleVersion` is optional and defaults to the title revision Hoplite has now; a rename that loses a race fails with `data.code: "thread_version_conflict"`. The result is `{ title, titleVersion? }`, and the renaming connection receives no `session_info_update` for its own rename.

To name a thread when you start it, pass `_meta.hoplite.title` on the v1 `session/prompt` that creates it, the session's first:

```json
{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"<session-id>","prompt":[{"type":"text","text":"Fix the flaky auth test."}],"_meta":{"hoplite":{"title":"Flaky auth test"}}}}
```

The thread is created with that title, trimmed, and Hoplite's automatic title never replaces it, as after a rename. Hoplite then sends `session_info_update` with the title. A title that is blank after trimming is ignored, so the thread gets an automatic title; a title that is not a string fails with `-32602`. On a prompt to a thread that already exists, the field is ignored: use `_hoplite/session/rename`.

### Failed runs

When a run fails, Hoplite sends `_hoplite/run_failed` with the failure the web app's failed-run card shows, and a failed prompt's response (`stopReason: "refusal"`) carries the same object in `_meta.hoplite.failure`. The notification is sent for teammates' runs too. It travels on the session stream, so it can arrive just before or just after the prompt response.

The failure's reason also streams as message text, so a client without Hoplite's extensions still shows why the run stopped. That update is marked with `_meta.hoplite.failureNotice: true` and the run's `runId`. A client that renders `_hoplite/run_failed` as a card can drop it to avoid showing the reason twice:

```json
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"<session-id>","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Out of credits"},"_meta":{"hoplite":{"failureNotice":true,"runId":"run_7"}}}}}
```

On ACP v2 the same mark is on the `agent_message` update that carries the reason.

```json
{
  "jsonrpc": "2.0",
  "method": "_hoplite/run_failed",
  "params": {
    "sessionId": "<session-id>",
    "runId": "run_7",
    "failure": {
      "code": "platform_inference_credits_exhausted",
      "category": "credits",
      "title": "Out of credits",
      "message": "This run used everything the workspace can spend right now, so it stopped. Add prepaid credits, then retry the run.",
      "retriable": true,
      "runId": "run_7",
      "error": "Workspace credits exhausted: balance $1.00, run needs $3.00.",
      "workspacePlan": "pro",
      "ledger": { "availableMicros": 1000000, "spentMicros": 3000000, "shortfallMicros": 2000000 },
      "actions": [
        { "id": "add_credits", "label": "Add credits", "url": "https://app.hoplite.sh/settings/workspace/billing" },
        { "id": "retry", "label": "Retry run", "method": "_hoplite/run/retry", "params": { "sessionId": "<session-id>" } }
      ]
    }
  }
}
```

| Field | Value |
| --- | --- |
| `code` | Hoplite's error code, such as `run_failed`, `provider_overloaded` or `codex_usage_limit_reached` |
| `category` | `credits`, `usage_limit`, `subscription_plan`, `sandbox_limit`, `reauth` (reconnect ChatGPT), `transient` (provider overloaded or rate limited), or `error` |
| `title`, `message` | The card's title and explanation |
| `error` | Optional redacted technical detail (the card's "Technical details") |
| `resetsAt` | `codex_usage_limit_reached` only: when the ChatGPT usage window resets, if known |
| `ledger` | Out of credits on a paid plan only, in micro-dollars |
| `subscriptionFallback` | `subscription_plan_model_unavailable` only: `{ modelId, modelDisplayName, option }`, where `option` is `credits`, `free_allowance`, `add_credits`, `upgrade`, or `unavailable` |
| `workspacePlan` | `free` or `pro`, when known |
| `actions` | The card's buttons, in order |

Each action has an `id` and a `label`, plus one of: `method` and `params`, an extension request to send as is; `url`, a page in the Hoplite web app to open; or `configId` and `value`, a `session/set_config_option` to apply before sending the previous prompt again (`switch_model`, which the web app offers when a ChatGPT subscription runs out). `availableAt` marks a retry that only makes sense after a usage window resets.

| Action `id` | Offered for |
| --- | --- |
| `retry` | Retriable failures, usage limits, and sandbox limits |
| `continue_on_hoplite` | A ChatGPT plan without the model, when Hoplite credits or the free allowance can run it: `_hoplite/run/retry` with `subscriptionFallback` |
| `continue_in_new_thread` | Every failure except running out of credits |
| `add_credits`, `upgrade` | Out of credits (`upgrade` first on the Free plan), sandbox limit, or the ChatGPT-plan offer |
| `reconnect` | `codex_reauth_required` |
| `switch_model` | ChatGPT usage limit or plan failures |

`_hoplite/run/retry` retries the thread's last turn, like the web app's `/retry`. Params: `{ sessionId, subscriptionFallback?: { failedRunId, funding: "credits" | "free_allowance" }, clientOperationId? }`. It behaves like a prompt: the new run streams on the session, its approvals and questions come to this client, and the response arrives when the run ends, as `{ stopReason, _meta: { hoplite: { runId, failure? } } }`. It needs thread retry (the `thread:update` scope for OAuth) and fails with `thread_has_active_run` while a run is going, and with `prompt_in_flight` while one of this connection's prompts is.

`_hoplite/thread/continue` starts a new thread in the same project from this one's transcript and workspace, like the web app's "Continue in new thread". Params: `{ sessionId, clientOperationId? }`. The result is `{ sessionId, title, runId, threadUrl? }`: the new thread is already working, so open it with `session/load` to follow its run. That run was not prompted by this connection, so it streams as live activity (see [Live thread activity](#live-thread-activity) for which of its approvals and questions come to this client). It needs thread create and read, and an active workspace subscription.

## Thread tools

Hoplite offers the web app's thread tools as `_hoplite/*` extension requests. Each takes the session ID as `sessionId`, so send it with the `Acp-Session-Id` header; the response arrives on that session's stream. Each request calls the same handler as the web app and needs the same permission, plus the OAuth scope in the table:

| Request | Web app equivalent | Permission | OAuth scope |
| --- | --- | --- | --- |
| `_hoplite/pr/create` | `/pr`, **Create pull request** | thread update, active workspace | `thread:update` |
| `_hoplite/pr/status` | Pull request chip | thread read | `thread:read` |
| `_hoplite/pr/merge` | **Merge** in the pull request panel | repository update | `thread:update` |
| `_hoplite/pr/ready` | **Ready for review** on GitHub | thread update, active workspace | `thread:update` |
| `_hoplite/pr/comments` | Review comments, **Add to chat** | thread read | `thread:read` |
| `_hoplite/pr/review_loop` | **Auto-fix** and **Auto-merge** | read: thread read; change: repository update, and an active workspace to turn one on | `thread:read` / `thread:update` |
| `_hoplite/thread/links` | Preview, terminal and subagent panels | thread read; `terminalUrl` needs thread update and an active workspace | `thread:read` |
| `_hoplite/thread/usage` | Context gauge and cost | thread read | `thread:read` |
| `_hoplite/thread/diff` | Thread diff, `mode` optional (`uncommitted` by default); patches capped at 512 KiB | thread read | `thread:read` |
| `_hoplite/instructions/get` | Personal and project instructions, optional `sessionId` | project read | `project:read` |
| `_hoplite/mcp/list` | Project and organization MCP server names and status, optional `sessionId`; never returns config values | MCP server read | `project:read` |
| `_hoplite/skills/list` | Skills in the composer menu | project read | `project:read` |
| `_hoplite/thread/compact` | **Compact** | thread retry, active workspace for cloud threads | `thread:update` |
| `_hoplite/thread/archive` | **Archive** / **Unarchive** | thread update; closing or drafting the PR also needs an active workspace | `thread:update` |
| `_hoplite/thread/delete` | **Delete** | thread delete (owners, admins and members); closing or drafting the PR also needs an active workspace | `thread:delete` (not among the scopes an OAuth client can request) |
| `_hoplite/thread/stop` | **Stop** | thread stop | `thread:update` |
| `_hoplite/thread/checkpoints` | Rewind points on replies | thread read | `thread:read` |
| `_hoplite/thread/restore_checkpoint` | **Rewind to here** | thread update, active workspace | `thread:update` |
| `_hoplite/thread/export` | **Export transcript** | thread read | `thread:read` |
| `_hoplite/feedback/rating` | Thumbs up or down on a reply | a member: OAuth, or a key created by a member | `thread:read` |

Errors are JSON-RPC errors whose `data.code` is the web API's error code: `-32001` when a scope or permission is missing (`data.permission` names it), `-32004` for `thread_not_found`, `pull_request_not_found`, `review_loop_not_found` or `checkpoint_not_found`, `-32602` for invalid params, and `-32000` for the rest, such as `thread_has_active_run`, `merge_rejected` (the message is GitHub's reason), `disposition_rejected`, `subscription_required` or `rate_limited`.

### Pull requests

`_hoplite/pr/create` accepts optional `title`, `body` and `draft` (default `true`, like the web app, with the thread title as the default title). If the thread already has a pull request, it returns that one with `created: false`, as `/pr` does:

```json
{"created":true,"pullRequest":{"number":42,"url":"https://github.com/acme/web/pull/42","repoFullName":"acme/web","state":"open","draft":true}}
```

`_hoplite/pr/status` returns `{ "pullRequest": <card> }`, or `null` without a pull request. The card holds what the pull request chip and panel show:

```json
{
  "number": 42, "title": "Fix the flaky auth test", "url": "https://github.com/acme/web/pull/42",
  "repoFullName": "acme/web", "state": "open", "draft": false,
  "mergeable": "mergeable", "mergeQueued": false,
  "checks": { "total": 2, "completed": 2, "succeeded": 2, "failing": 0, "running": 0, "available": true },
  "checkRuns": [{ "name": "test", "status": "completed", "conclusion": "success", "url": null }],
  "verdict": { "kind": "ready", "label": "Ready to merge" },
  "canMerge": true,
  "mergeMethods": [{ "method": "squash", "label": "Squash and merge" }],
  "defaultMergeMethod": "squash",
  "baseRef": "main", "headRef": "hoplite/fix-auth",
  "additions": 12, "deletions": 3, "changedFiles": 2,
  "unresolvedComments": 0,
  "reviewLoop": { "enabled": true, "autoMerge": false, "status": "observing" }
}
```

`state` is `open`, `closed` or `merged`. `verdict.kind` is the chip's verdict, in order of precedence: `merged`, `closed`, `draft`, `queued`, `conflicts`, `checks_failed`, `checks_running`, `comments`, `ready`, then `open`. `canMerge` follows the merge button: open, not a draft, not queued, mergeable, no failing or running checks, and at least one passing check (or checks readable and none configured). `unresolvedComments` is `null` until GitHub's comments are known.

After a session opens, Hoplite sends the card as the `_hoplite/pr_status` notification whenever it changes: when the pull request is opened, checks or mergeability move, it is merged or closed, or its review loop changes. The first read after `session/load` sends the current card if the thread has a pull request.

```json
{"jsonrpc":"2.0","method":"_hoplite/pr_status","params":{"sessionId":"<session-id>","pullRequest":{"number":42,"state":"open","verdict":{"kind":"checks_running","label":"Checks running"}},"significance":"checks_started"}}
```

A card is pushed on every change, including each check that finishes, so `significance` says what this push changed since the last card sent to the session. A client can print a new card only for a milestone and update a status line for `other`. It takes the first value that applies, in this order:

| `significance` | Meaning |
| --- | --- |
| `initial` | The session's first card, for a pull request that already existed |
| `created` | A pull request on a thread where the session last saw none |
| `merged`, `closed` | The pull request was merged or closed |
| `checks_failed` | The first check failed |
| `checks_completed` | Every check finished and none failed |
| `checks_started` | Checks started running where there were none, where all had passed, or where failed checks were re-run |
| `mergeable_changed` | GitHub's `mergeable`, draft or ready, the merge queue, or `canMerge` changed |
| `review_loop_changed` | Autofix or auto-merge was turned on or off, or the review loop started fixing, became ready, or hit an error |
| `other` | Anything else: checks progressing within a phase, comments, title, diff size, a reopen, the review loop moving through `observing`, `waiting_checks`, `finalizing` or `blocked`, the pull request going away (`pullRequest: null`), or the same card sent again |

While Hoplite refreshes its copy of the pull request from GitHub, a card can arrive with `checks.available: false`, `mergeable: "unknown"`, or both. A missing piece never counts as a change: checks only count when `checks.available` is true, mergeability only when it is not `"unknown"`, and each is compared with its last known value rather than with the gap. After a new commit, the new commit's first checks are compared fresh, so they still produce `checks_completed` or `checks_failed`.

`_hoplite/pr/merge` takes `method` (`squash` by default, `merge` or `rebase`) and, except for a rebase, optional `commitTitle` and `commitMessage`. It returns `{ merged, sha, message }`; `merged: false` carries GitHub's `message`.

`_hoplite/pr/ready` marks the thread's draft pull request ready for review on GitHub, the reverse of converting it to a draft when you archive. It returns `{ changed, draft, number, state }`. A pull request that is already ready, closed or merged comes back with `changed: false` and is left alone. If GitHub refuses, the error's `data.code` is `ready_rejected` with GitHub's reason, and `pull_request_repository_unavailable` means Hoplite can no longer write to the repository. The updated card follows as a `_hoplite/pr_status` notification.

`_hoplite/pr/comments` returns the unresolved review comments. Each comment has `id`, `authorLogin`, `body`, `path`, `line`, `url`, `isOutdated`, `added` (already sent to the agent) and `attachment`, the markdown the web app attaches when you choose **Add to chat**. To add comments to the chat, send `addToChatPrompt` ("Address the attached PR review comments.") as a `session/prompt`. Include each chosen comment's attachment as an embedded `resource` block, and list the comment IDs in `_meta.hoplite.prCommentIds` so Hoplite marks them as added. Each block becomes an attachment, as it does in the web app (see [Attachments](#attachments)). The web app numbers the comments you attach, in list order, and names them `pr-comment-<n>-<author>.md`. To get the same file names, use `file:///pr-comment-<n>-<attachment.authorSlug>.md` as the `uri`, where `<n>` is the comment's position among the comments you attach, starting at 1. `authorSlug` is the author's login in lowercase, with each run of other characters than `a`–`z`, `0`–`9` and `-` replaced by `-`, or `unknown` without a login. `attachment.name` is the name the comment gets when you attach every comment. A prompt can carry at most 10 attachments (the web app's limit too), so attach at most 10 comments at a time:

```json
{"sessionId":"<session-id>","prompt":[
  {"type":"text","text":"Address the attached PR review comments."},
  {"type":"resource","resource":{"uri":"file:///pr-comment-1-grace.md","mimeType":"text/markdown","text":"# PR #42 review comment — @grace\nReview thread id: rc_1\n..."}}],
 "_meta":{"hoplite":{"prCommentIds":["rc_1"]}}}
```

`_hoplite/pr/review_loop` without flags returns `{ "reviewLoop": … }` (or `null`). With `enabled` (auto-fix) and/or `autoMerge`, it changes them and returns the updated loop: `enabled`, `autoMerge`, `status` (`disabled`, `observing`, `waiting_checks`, `fixing`, `finalizing`, `ready`, `blocked` or `error`), `passCount`, `blockedReason` and the web app's other loop fields.

### Thread links

`_hoplite/thread/links` returns web app links to open in a browser:

```json
{
  "threadUrl": "https://app.hoplite.sh/threads/<id>",
  "previewUrl": "https://app.hoplite.sh/threads/<id>?tab=preview",
  "terminalUrl": "https://app.hoplite.sh/threads/<id>?tab=terminal",
  "subagentsUrl": "https://app.hoplite.sh/threads/<id>?tab=subagents",
  "pullRequestUrl": "https://github.com/acme/web/pull/42",
  "preview": {
    "status": "ready",
    "webUrl": "https://app.hoplite.sh/threads/<id>?tab=preview",
    "ports": [{ "name": "web", "port": 3000, "status": "ready", "webUrl": "https://app.hoplite.sh/threads/<id>?tab=preview&previewPort=3000" }]
  }
}
```

`terminalUrl` is present only when you may use the terminal. `preview` is the thread's preview as the web app reads it: `status` is `ready`, `starting`, `stopped`, `crashed`, `unsupported`, `inactive`, `blocked`, `reconciling`, or `error` (with `reason: "preview_unavailable"`) when it could not be read, with an optional `reason`. `webUrl` opens the preview in the web app, which signs you in. With an API key, `preview` and each port also carry `url`, the signed preview URL itself, as `GET /api/threads/:id/preview` returns it to API keys. It works for anyone who holds it for up to an hour, so treat it as a secret. An OAuth session gets only `webUrl`. Reading the preview can resume a stopped workspace, so pass `"preview": false` for the links alone.

### Context and cost

`session/new`, v1 `session/load` and v2 `session/resume` return `_meta.hoplite.usage`, and after every run on the session Hoplite sends the standard ACP `usage_update`:

```json
{"sessionUpdate":"usage_update","used":48000,"size":262144,"cost":{"amount":1.25,"currency":"USD"},
 "_meta":{"hoplite":{"modelId":"moonshotai/kimi-k3","contextSource":"provider","runCount":2,"inputTokens":90000,"outputTokens":4000,
   "subscription":null,"subscriptionOnly":false,
   "costBreakdown":{"totalUsd":1.25,"llmUsd":1,"vmUsd":0.25,"vmStatus":"available","spendLimitUsd":null,"byok":false,"gateway":false}}}}
```

`used` is the context the model last reported (0 before the first model call), `size` is the context window of the model the session runs with. `cost` is `null` where the web app shows no amount: before any usage, or when a subscription (Claude Code, Grok or ChatGPT, named in `subscription`) paid for everything. `costBreakdown` splits a shown `cost` into model (`llmUsd`) and sandbox (`vmUsd`, with `vmStatus` `available`, `pending` or `unavailable`) cost, with the thread's spend limit. `_meta.hoplite.usage` holds the same fields flat: `{ used, size, cost, modelId, contextSource, … }`. `_hoplite/thread/usage` returns that object on request. Usage is left out when Hoplite cannot read it.

### Skills

After `session/new` and `session/load`, Hoplite sends the session's skills as a standard ACP `available_commands_update`: the project's repository skills (`.agents/skills`, `.claude/skills`) followed by your own skills from **Settings → Skills**. A project skill wins a name clash. Nothing is sent when there are no skills, or while the workspace has no active access.

```json
{"sessionUpdate":"available_commands_update","availableCommands":[
  {"name":"review-pr","description":"Review a pull request","input":{"hint":"optional instructions"},
   "_meta":{"hoplite":{"kind":"skill","source":"project","path":".agents/skills/review-pr/SKILL.md"}}}]}
```

`_hoplite/skills/list` returns the same list as `{ "skills": [{ name, description, source: "project" | "user", path? }] }`; without `sessionId` it uses the endpoint's `projectId`.

A prompt invokes a skill the way the web composer does: a prompt that starts with `/<name>` runs that skill, with the rest of the text as its instructions. To tag a prompt explicitly, pass `_meta.hoplite.invokedSkill` on `session/prompt`; it takes precedence over the text.

### Subagents and the agent's plan

Delegated tasks already stream as `tool_call` updates of kind `think` titled "Delegated task: <name>", grouped with the run's other tool calls. Hoplite also sends the run's subagents as the `_hoplite/subagents` notification whenever one starts, waits for approval, or ends:

```json
{"jsonrpc":"2.0","method":"_hoplite/subagents","params":{"sessionId":"<session-id>","runId":"run_1","subagents":[
  {"id":"sub_1","toolCallId":"sub_1","name":"explorer","status":"completed","prompt":"Find the flaky test",
   "summary":"It races the clock.","startedAt":"2026-09-24T12:00:00.000Z","updatedAt":"2026-09-24T12:00:04.000Z"}]}}
```

`status` is `running`, `waiting_approval`, `completed`, `failed` or `interrupted`; `toolCallId` matches the delegated task's `tool_call`. While the tool call that started a subagent (such as `delegate_tasks`) is still running, the subagent has that call's `toolCallId` and no `tool_call` of its own, so a client can show it under the call's row. A subagent that outlives its call (`agent_spawn` returns as soon as its subagent starts) moves to its own ID and gets its own `tool_call` updates. `summary` is what the subagent reported, or its error, and is left out until it reports one. Each notification holds every subagent the run has started so far.

The agent's task list, shown as **Agent tasks** in the web app, arrives as a standard ACP `plan` update each time the agent changes it, including a long list whose recorded preview was cut (Hoplite reads the whole list). Every entry has priority `medium`; a cancelled task is `completed` with `_meta.hoplite.status: "cancelled"`.

### Thread actions

| Request | Params | Result |
| --- | --- | --- |
| `_hoplite/thread/compact` | — | `{ result: { threadId, runId, status } }`; the compaction runs as a run on the thread |
| `_hoplite/thread/stop` | — | `{ stopped, runId }`: stops the thread's active run, whoever started it |
| `_hoplite/thread/checkpoints` | `limit?` (20, at most 100) | `{ checkpoints: [{ checkpointId, messageId, runId, createdAt, preview }] }`, newest first |
| `_hoplite/thread/restore_checkpoint` | `checkpointId` | `{ result: { threadId, checkpointId, status } }`: discards the reply and everything after it and restores the workspace |
| `_hoplite/thread/export` | `format?` (`markdown`, `json`), `level?` (`messages`, `activity`, `full`), `subagents?` | `{ filename, contentType, format, level, content }` |
| `_hoplite/feedback/rating` | `sentiment` (`positive`, `negative`), `messageId?`, `runId?`, `reasonChips?`, `comment?`, `notify?`, `ratedAt?` | `{ id, messageId, revision? }`; without `messageId`, the latest reply (of `runId`, when given) is rated. A rating older than the reply's latest (by `ratedAt`) is not saved and returns `superseded: true` |
| `_hoplite/thread/archive` | `archived?` (default `true`), `prDisposition?`, `comment?` | see below |
| `_hoplite/thread/delete` | `prDisposition?`, `comment?` | `{ deleted: true, disposition? }`, or `{ deleted: false, needsDisposition }` as for archiving |

`session/cancel` stops only the run of this client's prompt; `_hoplite/thread/stop` is the web app's **Stop** button. Exports return at most 8 MB inline and share the web app's limit of 30 exports a minute. The web app's rating reasons are "Nailed it", "Good approach" and "Fast" for a positive rating and "Wrong approach", "Didn't follow instructions", "Broke the build", "Too slow" and "Incomplete" for a negative one; send `notify: true` once the user has chosen them.

A user can rate a reply twice in quick succession, for example thumbs up and then thumbs down, and the two requests can arrive out of order. Send `ratedAt`, an ISO 8601 time taken when the user rated, with each rating so the latest one is kept. Hoplite saves one reply's ratings one at a time, in the order they arrive on the connection. It skips any rating whose `ratedAt` is earlier than the last one saved with a `ratedAt`, and returns that rating's `id` and `revision` with `superseded: true`. The order is kept per connection only. A rating without `ratedAt` is always saved, so the last one to arrive wins, as in the web app.

Archiving a thread whose pull request is still open first applies what should happen to the pull request, as the web app does. With the workspace's archive setting on **Ask every time**, and no `prDisposition`, the request archives nothing and returns the choices to ask:

```json
{"archived":false,"needsDisposition":{
  "pullRequest":{"number":42,"url":"https://github.com/acme/web/pull/42","repoFullName":"acme/web","draft":false},
  "recommended":"draft",
  "choices":[
    {"action":"leave","label":"Leave the pull request open","description":"Keep #42 exactly as it is on acme/web.","acceptsComment":false},
    {"action":"draft","label":"Convert to draft","description":"Converts it back to a draft so reviewers stop being asked for attention.","acceptsComment":false},
    {"action":"close","label":"Close the pull request","description":"Closes the pull request without merging. You can add a comment explaining why.","acceptsComment":true}]}}
```

Call it again with `prDisposition` (and `comment` for `close`). Otherwise Hoplite applies the workspace's setting. The result is `{ archived: true, disposition? }`, where `disposition` is `{ action, changed, commented, … }`; if the pull request cannot be changed, nothing is archived. `archived: false` unarchives and never touches the pull request.

`_hoplite/thread/delete` deletes the thread for everyone in the workspace and can't be undone, so ask the user to confirm first. It handles an open pull request the same way, using the workspace's delete setting instead of its archive setting. It needs the thread delete permission, which owners, admins and members have. It also needs the `thread:delete` scope, which OAuth clients can't request, so third-party OAuth clients can't delete over ACP. Hoplite CLI keys created before the CLI could delete threads lack the permission; running `hoplite login` again creates a key that has it.

### Sandbox commands

`_hoplite/sandbox/exec` runs one non-interactive command in the thread's sandbox and returns its output. When the server supports it, `initialize` returns `_meta.hoplite.sandboxExec: true`.

It needs the same access as the web app's terminal, which is thread update and an active Hoplite plan, and it works on any thread in the workspace, not only your own. It also needs a Hoplite CLI key minted by a member (`hoplite login`), and that member's current role is checked on every call. OAuth sessions, service keys and workspace keys without a member are refused with `-32001` and `data.code: "principal_not_allowed"`. Each member can run 30 commands a minute, whichever of their CLI keys they use. Running commands and open terminals share a limit of 4 per member at a time.

| Param | Meaning |
| --- | --- |
| `sessionId` | The thread |
| `argv` | The command as words, for example `["git", "status", "--short"]`. Hoplite quotes each word, so none is split or expanded |
| `command` | The command as shell text, for example `"npm test 2>&1 \| tail -20"`. Pass exactly one of `argv` or `command` |
| `cwd?` | The directory to run in. A relative path starts at the repository root, which is the default |
| `timeoutSeconds?` | Default 120, at most 3,600. At the limit the command gets `TERM`, then `KILL` 5 seconds later, and exits with 124 |
| `stdin?` | Standard input as base64, at most 512 KiB. Without it, standard input is empty |

```json
{"jsonrpc":"2.0","id":12,"method":"_hoplite/sandbox/exec","params":{"sessionId":"<session-id>","argv":["git","status","--short"]}}
{"jsonrpc":"2.0","id":12,"result":{"exitCode":0,"stdout":" M src/app.ts\n","stderr":"","truncated":{"stdout":false,"stderr":false},"durationMs":412}}
```

`stdout` and `stderr` are UTF-8 text. Bytes that are not valid UTF-8 may be replaced, so pipe binary output through `base64`. Each stream returns at most 2 MiB, and `truncated` says which streams were cut. `outputIncomplete` lists any stream the sandbox could not read back after the command finished. In that case `exitCode` is still the real one. `durationMs` is how long the command took in the sandbox, including uploading `stdin`.

The command runs in a login `bash` like the web terminal, with the sandbox's base environment and none of the project's environment variables or secrets. The result comes back when the command exits. A background process it started (`npm run dev &`) is not waited for and keeps running, and its later output is not returned. Cancelling the request with `$/cancel_request` stops the command in the sandbox too. Commands may run while an agent run is active, as in the web terminal, so avoid changing files the agent is working on.

A thread that cannot run commands fails with `data.code`:

| `data.code` | JSON-RPC code | When |
| --- | --- | --- |
| `workspace_stopped` | `-32000` | The thread has no ready workspace, for example because it is archived |
| `setting_up` | `-32000` | Workspace setup has not finished yet |
| `setup_failed` | `-32000` | Workspace setup failed |
| `unsupported` | `-32000` | The thread runs on a local agent, or the server has no sandboxes |
| `forbidden` | `-32001` | The workspace is quarantined, like a fork pull request's command workspace |
| `rate_limited` | `-32000` | Over 30 commands a minute. `data.retryAfterSeconds` says when to retry |
| `too_many_shells` | `-32000` | The member already has 4 terminals and sandbox commands running |

The error's `message` explains the refusal in words.

### Terminal

The web app's terminal is a WebSocket at `wss://<api>/api/threads/<session-id>/terminal/ws`, using the `tty` subprotocol ([ttyd](https://github.com/tsl0922/ttyd)'s framing). A Hoplite CLI key minted by a member can open it too. Send the key as `Authorization: Bearer hop_...`, with no cookie and no `X-Api-Key` header. It needs the same access as `_hoplite/sandbox/exec`, and the same principals are refused.

Each person can have 4 terminals open at once, across the web app and the CLI, and can open 30 a minute. A refused upgrade gets a plain HTTP response with an `X-Hoplite-Terminal-Reason` header and a JSON body `{ "error": "<explanation>", "reason": "<reason>" }`:

| Status | `reason` |
| --- | --- |
| 401 | `unauthorized` |
| 403 | `forbidden`: no thread update, a key that is not a member's CLI key, an untrusted browser origin, or a quarantined workspace |
| 404 | `not_found`, or `unsupported` when the server or sandbox provider has no terminal |
| 409 | `workspace_stopped`, `setting_up`, `setup_failed`, or `terminal_failed` when the terminal could not start |
| 429 | `rate_limited`, or `too_many_shells` when 4 terminals and sandbox commands are already open |
| 502, 503 | `upstream_error` or `unavailable`; try again |

## Local tools

A client can offer the agent the tools of MCP servers on its user's machine, as the Hoplite CLI's [`--mcp`](/docs/cli/interactive#mcp-servers-on-your-machine) does. `initialize` advertises this in `_meta.hoplite.clientTools`: the two method names, `callTimeoutMs` (60,000), and `maxResultBytes` (524,288).

`_hoplite/client_tools/register` offers a session's tools. The session must be one the connection may update, and the credential must belong to a workspace member. Registering again from the same connection replaces the set. The tools last as long as the connection:

```json
{"jsonrpc":"2.0","id":5,"method":"_hoplite/client_tools/register","params":{"sessionId":"<session-id>","servers":[{"name":"paper","writes":false,"trusted":false,"tools":[{"name":"get_basic_info","description":"Get context","inputSchema":{"type":"object"},"annotations":{"readOnlyHint":true}},{"name":"write_html","inputSchema":{"type":"object"}}]}]}}
{"jsonrpc":"2.0","id":5,"result":{"servers":[{"name":"paper","label":"mcp_server_901","tools":["get_basic_info"],"hiddenWriteTools":1,"writes":false,"trusted":false}],"dropped":[],"limits":{"callTimeoutMs":60000,"maxResultBytes":524288}}}
```

| Field | Meaning |
| --- | --- |
| `servers[].name` | Lowercased to letters, digits, and underscores. Up to 8 servers |
| `servers[].tools` | The server's `tools/list` entries, up to 64. Names must match `[A-Za-z0-9_.-]{1,64}`. Descriptions are cut to about 40 plain words, and input schemas to a small JSON Schema subset under 8 KB |
| `servers[].writes` | Offer tools without `annotations.readOnlyHint: true`. Without it they are left out and counted in `hiddenWriteTools` |
| `servers[].trusted` | Calls to the server need no approval |
| `label` | The name the agent sees, fixed for this server name on the thread |
| `dropped` | What was refused: `server:<name>` or `<server>:<tool>` |

Only runs started by the registering user's own messages see the tools. When the agent calls one, Hoplite sends `_hoplite/client_tools/call` to the connection that registered it. The answer is the MCP `tools/call` result (`content`, optional `isError` and `structuredContent`), or a JSON-RPC error whose message the agent reads:

```json
{"jsonrpc":"2.0","id":"c1","method":"_hoplite/client_tools/call","params":{"sessionId":"<session-id>","callId":"<uuid>","server":"paper","tool":"get_basic_info","arguments":{}}}
```

Answer within `callTimeoutMs`. A larger result than `maxResultBytes` is refused. A client should enforce the read-only rule itself and refuse any tool it didn't offer.

Register before `session/load` when resuming a thread: a run that is already waiting on a local tool's approval is asked on the connection that holds the tool when the load starts following the run.

Unless the server is trusted or already allowed, each call first waits on a tool approval in the run. Hoplite asks for it with `session/request_permission` on the registering connection only. The request's `toolCall._meta.hoplite.localTool` holds `server`, `tool`, and `readOnlyHint`, and the options add `approve_server` (`allow_always`), which allows the server for the rest of the connection. Only that connection can resolve the approval. `POST /api/threads/:id/approvals/:approvalId` refuses it with 409 `tool_approval_requires_local_cli`.

### Local commands

A client whose user opted the machine in to [local commands](/docs/cli/interactive#commands-on-your-machine) adds `localExec` to the registration (`_meta.hoplite.clientTools.localExec` in `initialize` names the method; `servers` may be empty):

```json
{"jsonrpc":"2.0","id":6,"method":"_hoplite/client_tools/register","params":{"sessionId":"<session-id>","servers":[],"localExec":{"root":"/Users/me/code/app","platform":"macos"}}}
```

`root` is an absolute path, the directory commands run in by default; `platform` is a short lowercase name. The result's `localExec` is `{"accepted":true,"orgEnabled":true}`, `{"accepted":true,"orgEnabled":false}` (kept, but the workspace doesn't have local commands, so no run is offered them), or `{"accepted":false,"reason":"not_thread_owner"}` (only on threads the user started) or `"invalid"`. Hoplite offers the agent's `local_exec` tool only to runs the registering user started, while the workspace is entitled (checked for every command) and the connection is open.

Each command is a tool approval in the run, asked with `session/request_permission` on the registering connection only. `toolCall.rawInput` holds `command`, and optionally `cwd` and `timeoutSeconds`; `toolCall._meta.hoplite.localExec` holds `root` and `platform`; the options are `approve` (`allow_once`) and `reject` (`reject_once`). No option allows more than that one call. As for local tools, only that connection can resolve it. Once approved, Hoplite sends:

```json
{"jsonrpc":"2.0","id":"c2","method":"_hoplite/local_exec/run","params":{"sessionId":"<session-id>","toolCallId":"<tool-call-id>","command":"xcodebuild -scheme App","cwd":"ios","timeoutMs":120000}}
```

Answer with `{"cwd","exitCode","signal","timedOut","durationMs","stdout","stdoutOmittedBytes","stderr","stderrOmittedBytes"}`, within `timeoutMs` plus 30 seconds, or with a JSON-RPC error whose message the agent reads. `$/cancel_request` naming the request stops it. A client must run only a command its user approved (exactly that command, in that resolved directory), or one a rule of the user's allows, whatever Hoplite sends.

## Minimal protocol flow

Initialize the transport with a non-null JSON-RPC request ID:

```json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"my-client","version":"1.0.0"}}}
```

Authenticate, create a session, then prompt:

```json
{"jsonrpc":"2.0","id":2,"method":"authenticate","params":{"methodId":"hoplite-oauth"}}
{"jsonrpc":"2.0","id":3,"method":"session/new","params":{"cwd":"/workspace","mcpServers":[]}}
{"jsonrpc":"2.0","id":4,"method":"session/prompt","params":{"sessionId":"<session-id>","prompt":[{"type":"text","text":"Fix the failing authentication test and open a pull request."}]}}
```

Connected HTTP `POST` requests return `202 Accepted`; read their JSON-RPC responses and `session/update` notifications from the SSE stream, then send `DELETE /acp` with `Acp-Connection-Id` when finished. WebSocket clients send and receive the same JSON-RPC messages as text frames and close the socket when finished.

For ACP v2, initialize with protocol version `2`, use `auth/login`, and use `session/resume` instead of `session/load`:

```json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":2,"info":{"name":"my-client","version":"1.0.0"}}}
{"jsonrpc":"2.0","id":2,"method":"auth/login","params":{"methodId":"hoplite-oauth"}}
{"jsonrpc":"2.0","id":3,"method":"session/resume","params":{"sessionId":"<session-id>","cwd":"/workspace","replayFrom":{"type":"start"}}}
```

ACP v2 acknowledges `session/prompt` when the prompt is accepted, then reports `state_update` notifications for `running` and `idle`. The final `idle` update includes the stop reason.

## Connection limits

Each credential (an OAuth session or an API key) can hold 32 ACP connections at a time, each user 64 across all their sessions and keys, and each workspace 1,000 per API server. A connection is in use while its client reads one of its event streams (or its WebSocket is open) or while a prompt on it is running. When a new connection would exceed a limit, Hoplite closes the least recently used connection in that scope that is not in use, such as one left behind by a client that exited without sending `DELETE`. Only when every connection in the scope is in use does `initialize` fail with `429` and `Retry-After: 60`.

A connection follows at most 8 sessions live (see [Live thread activity](#live-thread-activity)), a user at most 32 across their connections, and a workspace at most 512 per API server. Past a limit, the oldest session that has no prompt running stops receiving live updates; load it again to resume them.

An HTTP connection and its event streams end 24 hours after the connection opened, however active it is. Requests on it then return `404`.

A connection that is not in use expires 30 minutes after its last request or stream. Connections in use expire 24 hours after their last request. Requests on a closed or expired connection return `404`; initialize a new connection and call v1 `session/load` or v2 `session/resume` to continue. Send `DELETE /acp` when you are done so the connection is released at once.

An unexpected server error fails with `-32603` and the message `Internal error`, with no `data`: details stay in Hoplite's logs. Refusals that you can act on (permissions, missing sessions, invalid params, access) keep their own codes and `data.code`.

Each HTTP request body and each WebSocket message can be at most 1 MiB, including any embedded resources. A larger HTTP request fails with `413` and the JSON-RPC error `-32600` ("Request body is too large."). A larger WebSocket message closes the socket with code `1009`.

## Protocol status

Hoplite supports stable ACP v1 and the current experimental ACP v2 draft on the same endpoint. The server routes each connection by the negotiated `initialize.protocolVersion`; v1 uses `authenticate` and `logout`, while v2 uses `auth/login` and `auth/logout`. ACP v2 may still change incompatibly before it becomes stable. Hoplite implements both profiles from the remote [Streamable HTTP and WebSocket transport](https://github.com/agentclientprotocol/agent-client-protocol/blob/c50e1cd2/docs/rfds/streamable-http-websocket-transport.mdx) RFD.

See the [ACP TypeScript SDK](https://github.com/agentclientprotocol/typescript-sdk) for a client transport implementation and the [v2 migration guide](https://agentclientprotocol.com/protocol/v2/migration) for the authentication-method differences.

## Sitemap

Sitemap discovery is not enabled for this deployment.
