Coding-agent CLIs that speak the Agent Client Protocol (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.
It is the harness equivalent of the OpenAI-Compatible adapter. Use it when your agent speaks ACP but has no @tanstack/ai-* package. If a dedicated harness adapter exists (Grok Build, and others), prefer it — those carry curated per-model metadata and vendor-specific behavior.
acpCompatible ships in @tanstack/ai-acp. You drive it inside a sandbox, so install the sandbox package and a provider too:
npm install @tanstack/ai-acp @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-dockerConfigure 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:
import { chat } from '@tanstack/ai'
import { acpCompatible } from '@tanstack/ai-acp'
import {
createSecrets,
defineSandbox,
defineWorkspace,
githubRepo,
withSandbox,
} from '@tanstack/ai-sandbox'
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
refusalMessage: 'Pi refused the request.',
})
const sandbox = defineSandbox({
id: 'pi-agent',
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
secrets: createSecrets({ PI_API_KEY: process.env.PI_API_KEY ?? '' }),
}),
})
const stream = chat({
adapter: pi('pi-fast'),
messages,
middleware: [withSandbox(sandbox)],
})You get the full ACP flow for free: sandbox resolution, chat()-tool → MCP bridging, session resume, permission handling, abort, and AG-UI event translation.
For a single model, skip the harness-factory and build the adapter inline with acpCompatibleText:
import { chat } from '@tanstack/ai'
import { acpCompatibleText } from '@tanstack/ai-acp'
import { withSandbox } from '@tanstack/ai-sandbox'
import { sandbox } from './sandbox'
import { messages } from './chat-context'
const stream = chat({
adapter: acpCompatibleText('pi-fast', {
name: 'pi',
command: ({ model }) => `pi --acp -m ${model}`,
}),
messages,
middleware: [withSandbox(sandbox)],
})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:
import { acpCompatible } from '@tanstack/ai-acp'
const pi = acpCompatible({
name: 'pi',
models: ['pi-fast', 'pi-pro'],
modelOptions: {} as { reasoningEffort?: 'low' | 'high' },
command: ({ model, harnessCwd, modelOptions }) =>
`pi --acp -m ${model} --cwd ${harnessCwd}` +
(modelOptions?.reasoningEffort ? ` --effort ${modelOptions.reasoningEffort}` : ''),
})
pi('pi-pro') // ok
// pi('pi-ultra') // type error — not in `models`import { chat } from '@tanstack/ai'
import { withSandbox } from '@tanstack/ai-sandbox'
import { pi } from './pi-harness'
import { sandbox } from './sandbox'
import { messages } from './chat-context'
const stream = chat({
adapter: pi('pi-pro'),
modelOptions: { reasoningEffort: 'high' }, // typed against the declared options
messages,
middleware: [withSandbox(sandbox)],
})The base options are always available on modelOptions regardless of what you declare: sessionId (resume), cwd, authMethodId, and permissionMode.
| 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 gitSkills 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. |
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:
import { acpCompatible, startAcpServerInSandbox } from '@tanstack/ai-acp'
const myAgent = acpCompatible({
name: 'my-agent',
openTransport: async ({ sandbox, model, harnessCwd, signal }) => {
const server = await startAcpServerInSandbox(sandbox, {
port: 9100,
cwd: harnessCwd,
command: `my-agent serve --bind 0.0.0.0:9100 -m ${model}`,
readyMarker: 'listening',
buildWsUrl: ({ channel, port }) =>
`${channel.url.replace(/^http/i, 'ws')}:${port}`,
...(signal ? { signal } : {}),
})
const ws = await server.connect(signal)
return {
kind: 'stream',
stream: ws.stream,
dispose: async () => {
ws.close()
await server.dispose()
},
}
},
})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':
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
})chat()-provided tools bridged into the agent are always auto-approved, regardless of mode.
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:
import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai'
import { withSandbox } from '@tanstack/ai-sandbox'
import { pi } from './pi-harness' // the configured `acpCompatible(...)` factory
import { sandbox } from './sandbox'
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const sessionId =
typeof params.forwardedProps.sessionId === 'string'
? params.forwardedProps.sessionId
: undefined
const stream = chat({
adapter: pi('pi-fast'),
messages: params.messages,
middleware: [withSandbox(sandbox)],
modelOptions: { sessionId },
})
return toServerSentEventsResponse(stream)
}When you provision a workspace via withSandbox, acpCompatible projects its skills into the harness — each kind lands where that harness expects it:
| Workspace input | How acpCompatible projects it |
|---|---|
| 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 (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. |
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.
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.
All streamed session/updates 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.
<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.
Not implemented (by design):
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).