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.
The default authMode is 'api-key'. Set 'host' when the agent should use a CLI login on the machine. The sandbox type does not pick this. See Harness Auth.
import { acpCompatibleText } from "@tanstack/ai-acp"
acpCompatibleText("composer-2.5", {
name: "acp",
command: ({ model }) => `grok agent -m '${model}' --always-approve stdio`,
authMethodId: "xai.api_key",
})
acpCompatibleText("composer-2.5", {
name: "acp",
command: ({ model }) => `grok agent -m '${model}' --always-approve stdio`,
authMode: "host",
})Pass outputSchema on the same chat() call. acpCompatible adds the schema to the prompt and parses the last assistant text. Native harness tools still run on that turn. Read the typed object from await chat(), from useChat().final, or from the assistant structured-output part. See Harness Agents.
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)],
})Pass outputSchema on the same chat() call. The agent runs its native tools. Then the adapter parses the last assistant text as JSON.
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { acpCompatibleText } from '@tanstack/ai-acp'
import { withSandbox } from '@tanstack/ai-sandbox'
import { z } from 'zod'
import { sandbox } from './sandbox'
import { messages } from './chat-context'
const ReportSchema = z.object({
name: z.string(),
oneLiner: z.string(),
})
export async function POST() {
const stream = chat({
adapter: acpCompatibleText('pi-fast', {
name: 'pi',
command: ({ model }) => `pi --acp -m ${model}`,
}),
messages,
outputSchema: ReportSchema,
stream: true,
middleware: [withSandbox(sandbox)],
})
return toServerSentEventsResponse(stream)
}On the client, walk messages[].parts for tool calls and reasoning. Read the typed object from the structured-output part or from useChat().final.
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
import { z } from 'zod'
const ReportSchema = z.object({
name: z.string(),
oneLiner: z.string(),
})
function Report() {
const { messages, final } = useChat({
connection: fetchServerSentEvents('/api/report'),
outputSchema: ReportSchema,
})
return (
<>
{messages.map((message) =>
message.parts.map((part, index) => {
if (part.type === 'tool-call') {
return <p key={part.id}>{part.name}</p>
}
if (part.type === 'structured-output') {
const report = part.data ?? part.partial
return report?.name ? <h2 key={index}>{report.name}</h2> : null
}
return null
}),
)}
{final ? <p>{final.oneLiner}</p> : null}
</>
)
}See Harness Agents for the full part list and the repo-report example.
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, authMode, 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. |
| authMode | 'api-key' (default) uses authMethodId. 'host' skips ACP authenticate. See Harness Auth. |
| authMethodId | ACP auth method to select before the session starts. Ignored when authMode is 'host'. |
| 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:
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):
Not implemented (by design):