Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 64 additions & 95 deletions docs/adapters/acp-compatible.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: ACP-Compatible Harness
id: acp-compatible-harness
description: "Plug any Agent Client Protocol (ACP) coding agent into a TanStack AI sandbox with one generic harness adapter — no dedicated package required."
description: "Plug any ACP coding agent into a TanStack AI sandbox — no dedicated package required."
keywords:
- tanstack ai
- acp
Expand All @@ -12,21 +12,24 @@ keywords:
- custom adapter
---

Coding-agent CLIs that speak the [Agent Client Protocol](https://agentclientprotocol.com) (ACP) — `grok`, `gemini --acp`, and others — expose a long-lived JSON-RPC session you can drive from a sandbox. Instead of a dedicated package per agent, `acpCompatible` builds a `chat()` adapter for **any** ACP-compliant CLI: configure how to launch it once, select a model per call, and pass it into a sandbox.
If you need a coding agent that speaks [ACP](https://agentclientprotocol.com) but has no `@tanstack/ai-*` package → use `acpCompatible`. Prefer a dedicated harness ([Grok Build](./grok-build), etc.) when one exists.

It is the harness equivalent of the [OpenAI-Compatible adapter](./openai-compatible). Use it when your agent speaks ACP but has no `@tanstack/ai-*` package. If a dedicated harness adapter exists ([Grok Build](./grok-build), and others), prefer it — those carry curated per-model metadata and vendor-specific behavior.
## When do I need this?

## Installation
- Agent CLI speaks ACP (`grok`, `gemini --acp`, …) and has no first-class adapter
- You already run agents inside a [sandbox](../sandbox/overview)

`acpCompatible` ships in `@tanstack/ai-acp`. You drive it inside a sandbox, so install the sandbox package and a provider too:
## Install

```bash
npm install @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
```

## Basic Usage
## Do this

Configure the harness once with `acpCompatible({ name, command })`, then select a model per call. `command` builds the shell command that launches the agent's ACP server over **stdio** inside the sandbox:
1. Configure the harness once with `acpCompatible({ name, command })`.
2. Select a model per call.
3. Pass `withSandbox(...)` middleware.

```ts
import { chat } from '@tanstack/ai'
Expand All @@ -41,11 +44,10 @@ import {
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
import { messages } from './chat-context'

// Configure the "pi" agent harness once:
const pi = acpCompatible({
name: 'pi',
command: ({ model, harnessCwd }) => `pi --acp -m ${model} --cwd ${harnessCwd}`,
authMethodId: 'pi-api-key', // when the harness advertises an ACP auth method
authMethodId: 'pi-api-key',
refusalMessage: 'Pi refused the request.',
})

Expand All @@ -54,7 +56,7 @@ const sandbox = defineSandbox({
provider: dockerSandbox({ image: 'node:22' }),
workspace: defineWorkspace({
source: githubRepo({ repo: 'owner/app' }),
setup: ['npm install -g pi-cli'], // install the agent CLI into the image
setup: ['npm install -g pi-cli'],
secrets: createSecrets({ PI_API_KEY: process.env.PI_API_KEY ?? '' }),
}),
})
Expand All @@ -66,11 +68,9 @@ const stream = chat({
})
```

You get the full ACP flow for free: sandbox resolution, `chat()`-tool → MCP bridging, session resume, permission handling, abort, and AG-UI event translation.
You get sandbox resolution, `chat()`-tool → MCP bridging, session resume, permission handling, abort, and AG-UI event translation.

## One-Shot Usage

For a single model, skip the harness-factory and build the adapter inline with `acpCompatibleText`:
### One-shot (single model)

```ts
import { chat } from '@tanstack/ai'
Expand All @@ -89,14 +89,9 @@ const stream = chat({
})
```

## Typed models & options
### Typed models and options

Like `openaiCompatible`, you can declare the harness's **models** and its
per-call **options** so the whole thing is type-checked. `models` constrains the
factory's argument; `modelOptions` is a type-only brand (`{} as { … }`, unused at
runtime) describing what `chat({ modelOptions })` accepts. Declared options are
merged with the base ACP options and handed to `command` / `openTransport` as
`ctx.modelOptions`, so you can turn them into CLI flags:
Declare `models` and a type-only `modelOptions` brand so calls type-check. Options merge with base ACP options and reach `command` / `openTransport` as `ctx.modelOptions`:

```ts
import { acpCompatible } from '@tanstack/ai-acp'
Expand All @@ -111,7 +106,7 @@ const pi = acpCompatible({
})

pi('pi-pro') // ok
// pi('pi-ultra') // type error — not in `models`
// pi('pi-ultra') // type error
```

```ts
Expand All @@ -123,40 +118,39 @@ import { messages } from './chat-context'

const stream = chat({
adapter: pi('pi-pro'),
modelOptions: { reasoningEffort: 'high' }, // typed against the declared options
modelOptions: { reasoningEffort: 'high' },
messages,
middleware: [withSandbox(sandbox)],
})
```

The base options are always available on `modelOptions` regardless of what you
declare: `sessionId` (resume), `cwd`, `authMethodId`, and `permissionMode`.
Base options always available on `modelOptions`: `sessionId`, `cwd`, `authMethodId`, `permissionMode`.

## Configuration

| Field | Purpose |
| --- | --- |
| `name` (required) | Harness label, log prefix, and the `<name>.session-id` CUSTOM event name. |
| `models` | The model ids this harness accepts — declaring them makes `harness('id')` type-safe (unknown ids are rejected). Omit to accept any string. |
| `modelOptions` | Type-only brand for the per-call options accepted via `chat({ modelOptions })`. Declare with `{} as { … }`; merged with the base options and exposed on `ctx.modelOptions` in `command` / `openTransport`. |
| `command` | Build the **stdio** launch command from `{ model, cwd, harnessCwd, sandbox, env, modelOptions, signal }`. Required unless `openTransport` is given. |
| `skillsDir` | The harness's skills directory (relative to the workspace root, e.g. `'.pi/skills'`) — its native convention, like Claude Code's `.claude/skills`. `withSandbox` workspace `gitSkill`s are linked here. Omit and gitSkills are left unlinked (warned). |
| `openTransport` | Open any `AcpSessionTransport` yourself (e.g. boot a `serve` process and connect over WebSocket). Overrides `command`. |
| `cwd` | Working directory inside the sandbox (default `/workspace`). |
| `env` | Extra environment variables for the harness process. |
| `authMethodId` | ACP auth method to select before the session starts. |
| `permissionMode` | `'default'` \| `'acceptEdits'` \| `'bypassPermissions'` (default). |
| `permissions` | `'headless'` (auto-resolve, default) or `'interactive'` (emit approval-requested events for `ask` prompts). |
| `onPermissionRequest` | Custom permission handler; overrides `permissions`/`permissionMode`. |
| `refusalMessage` | `RUN_ERROR` message when the harness refuses a request. |
| `planEventName` | Emit ACP `plan` updates as a CUSTOM event under this name. |
| `emitDiff` | Emit the post-run `git diff` of `cwd` as a `file.changed` CUSTOM event (off by default). |
| `onExtNotification` | Handle vendor `_x/…` JSON-RPC notifications. |
| `buildPrompt` | Override how chat history maps to the harness prompt. |

## WebSocket and Custom Transports

Some harnesses run an ACP server you reach over WebSocket rather than stdio (the `grok agent serve` pattern). Open the transport yourself with `openTransport` — it receives the same context and returns an `AcpSessionTransport`. Put all teardown in the returned transport's `dispose`:
| `name` (required) | Label, log prefix, and `<name>.session-id` CUSTOM event name |
| `models` | Accepted model ids type-safe factory args. Omit any string |
| `modelOptions` | Type-only brand for `chat({ modelOptions })` (`{} as { … }`) |
| `command` | Build stdio launch cmd from `{ model, cwd, harnessCwd, sandbox, env, modelOptions, signal }`. Required unless `openTransport` |
| `skillsDir` | Skills dir relative to workspace (e.g. `'.pi/skills'`) for `gitSkill` links |
| `openTransport` | Custom `AcpSessionTransport` (e.g. WebSocket). Overrides `command` |
| `cwd` | Working dir in sandbox (default `/workspace`) |
| `env` | Extra env for the harness process |
| `authMethodId` | ACP auth method before session start |
| `permissionMode` | `'default'` \| `'acceptEdits'` \| `'bypassPermissions'` (default) |
| `permissions` | `'headless'` (default) or `'interactive'` |
| `onPermissionRequest` | Custom permission handler |
| `refusalMessage` | `RUN_ERROR` message on refuse |
| `planEventName` | Emit ACP `plan` as CUSTOM under this name |
| `emitDiff` | Post-run `git diff` as `file.changed` CUSTOM (off by default) |
| `onExtNotification` | Vendor `_x/…` JSON-RPC notifications |
| `buildPrompt` | Override history harness prompt |

## WebSocket / custom transport

Some harnesses serve ACP over WebSocket. Open the transport yourself; put teardown in `dispose`:

```ts
import { acpCompatible, startAcpServerInSandbox } from '@tanstack/ai-acp'
Expand Down Expand Up @@ -188,29 +182,31 @@ const myAgent = acpCompatible({

## Permissions

Inside a sandbox the sandbox itself is the security boundary, so the default `'headless'` strategy with `permissionMode: 'bypassPermissions'` lets the agent edit files and run commands without prompting. To surface tool approvals to a client instead, switch to `'interactive'`:
Sandbox is the security boundary. Default: `'headless'` + `permissionMode: 'bypassPermissions'`. For client approvals:

```ts
import { acpCompatible } from '@tanstack/ai-acp'

const pi = acpCompatible({
name: 'pi',
command: ({ model }) => `pi --acp -m ${model}`,
permissions: 'interactive', // emit approval-requested events for `ask` prompts
permissionMode: 'acceptEdits', // still auto-approve file edits
permissions: 'interactive',
permissionMode: 'acceptEdits',
})
```

`chat()`-provided tools bridged into the agent are always auto-approved, regardless of mode.
`chat()`-bridged tools are always auto-approved.

## Session Resume
## Session resume

On every run the adapter emits the harness session id as a CUSTOM event named `<name>.session-id` (e.g. `pi.session-id`). Thread that id back through `modelOptions.sessionId` on the next call and the harness resumes the session — only the trailing user message is sent, since the agent already holds the prior context:
1. Capture `<name>.session-id` CUSTOM event (e.g. `pi.session-id`).
2. Pass it back as `modelOptions.sessionId`.
3. Send only the latest user message — harness holds prior context.

```ts
import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai'
import { withSandbox } from '@tanstack/ai-sandbox'
import { pi } from './pi-harness' // the configured `acpCompatible(...)` factory
import { pi } from './pi-harness'
import { sandbox } from './sandbox'

export async function POST(request: Request) {
Expand All @@ -233,54 +229,27 @@ export async function POST(request: Request) {

## Workspace skills

When you provision a [workspace](../sandbox/workspace) via `withSandbox`,
`acpCompatible` projects its skills into the harness — each kind lands where that
harness expects it:

| Workspace input | How `acpCompatible` projects it |
| Workspace input | Projection |
| --- | --- |
| `mcpSkill(name, config)` | Passed to the agent over **ACP natively** via `newSession`'s `mcpServers` (secrets/bearer headers resolved). No config file — that's the ACP advantage over file-based harnesses. |
| `gitSkill({ repo })` | Cloned during bootstrap, then linked into your declared [`skillsDir`](#configuration) (e.g. `.pi/skills`). Omit `skillsDir` and it's left unlinked (with a warning). |
| `fileSkill({ path, content })` | Written into the workspace root during bootstrap (provider-agnostic). |
| `instructions` | Written to `AGENTS.md` (and symlinks) during bootstrap. |
| `agentSkill(name)`, `plugins` | No generic ACP primitive — warned and skipped. Provide a `gitSkill` or MCP server instead. |
| `mcpSkill(name, config)` | ACP `newSession` `mcpServers` (secrets resolved) |
| `gitSkill({ repo })` | Cloned, linked into `skillsDir` |
| `fileSkill({ path, content })` | Written into workspace root |
| `instructions` | Written to `AGENTS.md` |
| `agentSkill` / `plugins` | No ACP primitive — warned and skipped |

`secrets` declared on the workspace are injected into the agent's environment at
create/resume (never persisted to snapshots), so the harness CLI picks them up
like any env var.
Workspace `secrets` inject into the agent env at create/resume (never snapshotted).

## Protocol coverage

`acpCompatible` implements the **client / orchestration** side of ACP — enough to
drive an agent through a full prompt turn, not the entire protocol surface. It is
a compliant *minimal* client: everything it doesn't implement is either
capability-gated (so advertising non-support is the spec-defined behavior) or a
rendering choice, never a violation.

**Covered:**

- `initialize` handshake — sends `clientInfo` + the protocol version, negotiates the version, advertises capabilities.
- `authenticate` (when the agent advertises auth methods), `session/new`, `session/load` (resume), `session/prompt`, `session/cancel`.
- `session/request_permission` with all four option kinds, mapped by [permission mode](#permissions).
- All streamed `session/update`s that carry turn output: `agent_message_chunk`, `agent_thought_chunk` (→ reasoning), `tool_call` / `tool_call_update`, `plan`.
- All five stop reasons (`end_turn`, `max_tokens`, `max_turn_requests`, `refusal`, `cancelled`).

**Surfaced as `CUSTOM` stream events** (the AG-UI chat-event protocol has no
first-class event for non-text assistant output, so these ride on `CUSTOM`):

- `<name>.session-id` — the harness session id, for [resume](#session-resume).
- `<name>.message-content` — non-text agent content (`image` / `audio` / `resource` / `resource_link` blocks). Its `value` is `{ content: <ACP content block> }`. Non-text **tool** content (diffs, terminal, images) is preserved inside the `TOOL_CALL_RESULT` payload.
- the plan event, when you set `planEventName`.
**Covered:** `initialize`, `authenticate`, `session/new` / `load` / `prompt` / `cancel`, `session/request_permission`, streamed updates (`agent_message_chunk`, `agent_thought_chunk`, tool calls, `plan`), all five stop reasons.

**Not implemented (by design):**
**CUSTOM events:** `<name>.session-id`, `<name>.message-content` (non-text blocks), plan (if `planEventName`).

- `fs/read_text_file`, `fs/write_text_file`, `terminal/*` — advertised as unsupported. The agent runs inside the sandbox with direct filesystem and shell access, so it never delegates these back to the client.
- Sending **multimodal prompts** — prompts are sent as text. (Agent multimodal *output* is surfaced via `message-content` above.)
- Incremental `usage_update` (final turn usage is reported instead), `available_commands_update`, `current_mode_update`, and experimental features (elicitation, NES, providers, session modes/config).
**Not implemented (by design):** `fs/*`, `terminal/*` (agent has sandbox access), multimodal prompts (text only), incremental `usage_update`, experimental ACP features.

## Next Steps
## Next steps

- [Sandbox Overview](../sandbox/overview) — how harnesses run inside a sandbox
- [Grok Build Adapter](./grok-build) — a first-class ACP harness adapter
- [Sandbox Tools](../sandbox/tools) — bridge your app's tools into the agent
- [OpenAI-Compatible Adapter](./openai-compatible) — the same idea for model providers
- [Sandbox Overview](../sandbox/overview)
- [Grok Build Adapter](./grok-build)
- [Sandbox Tools](../sandbox/tools)
- [OpenAI-Compatible Adapter](./openai-compatible)
Loading
Loading