- OpenAIUpdated
- OpenAI: AdvancedNew
- AnthropicUpdated
- Anthropic: AdvancedNew
- Google GeminiUpdated
- Google Gemini: AdvancedNew
- Google Vertex AIUpdated
- OllamaUpdated
- Grok (xAI)Updated
- GroqUpdated
- MistralUpdated
- CohereUpdated
- TypeSafeUpdated
- OllayaUpdated
- ElevenLabsUpdated
- fal.aiUpdated
- ReactorUpdated
- World LabsUpdated
- OpenRouter AdapterUpdated
- OpenRouter: AdvancedNew
- Perplexity SearchUpdated
- Vercel AI GatewayUpdated
- Lovable AI GatewayUpdated
- OpenAI-CompatibleUpdated
- OpenAI-Compatible: AdvancedNew
- LLM GatewayUpdated
- CloudflareUpdated
- Claude CodeUpdated
- CodexUpdated
- OpenCodeUpdated
- Grok BuildUpdated
- ACP-CompatibleUpdated
- Amazon BedrockUpdated
- BytePlusUpdated
- BytePlus: AdvancedNew
ACP-Compatible Harness
acpCompatible builds a chat() adapter for an ACP agent CLI that has no @tanstack/ai-* package. You pass the start command. The adapter runs the agent in a sandbox.
It is the harness version of the OpenAI-Compatible adapter. Does a dedicated harness adapter exist for your agent, such as Grok Build? Use that one. It knows the models and the special behavior of its agent.
You need:
- A sandbox provider. The samples use Docker, so Docker must run on your machine.
- An agent CLI that runs an ACP server over stdio, installed inside the sandbox. The samples use the grok CLI.
- The API key of that agent, or a CLI login on the machine.
1. Install#
npm i @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-buildpnpm add @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-buildyarn add @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-buildbun add @tanstack/ai @tanstack/ai-acp @tanstack/ai-sandbox @tanstack/ai-sandbox-docker @tanstack/ai-grok-buildThe samples import GROK_CLI_INSTALL_COMMAND from @tanstack/ai-grok-build, to install the grok CLI in the sandbox. For another agent, use its own install command and skip that package.
2. Set the API key#
Put the key of your agent in the environment of your server. For the grok CLI:
XAI_API_KEY=xai-...The sandbox in step 3 gives this key to the agent as a workspace secret. Then the adapter signs in through ACP authenticate, with the auth method that you name in authMethodId. For Grok, it is xai.api_key.
Did the machine already log in to the agent CLI? Set authMode: 'host'. The adapter then skips ACP authenticate. See Harness Auth.
3. Run the agent from the server#
Define the sandbox once. It clones your repo into a Docker container and installs the agent CLI:
// sandbox.ts
import { GROK_CLI_INSTALL_COMMAND } from "@tanstack/ai-grok-build";
import {
createSecrets,
defineSandbox,
defineWorkspace,
githubRepo,
} from "@tanstack/ai-sandbox";
import { dockerSandbox } from "@tanstack/ai-sandbox-docker";
export const sandbox = defineSandbox({
id: "acp-agent",
provider: dockerSandbox({ image: "node:22" }),
workspace: defineWorkspace({
source: githubRepo({ repo: "owner/app" }),
setup: [GROK_CLI_INSTALL_COMMAND],
secrets: createSecrets({
XAI_API_KEY: process.env.XAI_API_KEY ?? "",
}),
}),
});Then build the adapter with acpCompatibleText and pass it to chat(). command returns the shell command that starts the ACP server of the agent over stdio:
// api/agent.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { acpCompatibleText } from "@tanstack/ai-acp";
import { withSandbox } from "@tanstack/ai-sandbox";
import { sandbox } from "./sandbox";
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: acpCompatibleText("composer-2.5", {
name: "acp",
command: ({ model }) => `grok agent -m '${model}' --always-approve stdio`,
authMethodId: "xai.api_key",
}),
messages,
threadId,
runId,
middleware: [withSandbox(sandbox)],
});
return toServerSentEventsResponse(stream);
}Replace owner/app with your repo.
4. Render the agent's work#
The client is the same as for any chat. Render the text, and show each tool that the agent ran:
import { useState } from "react";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
export function CodingAgent() {
const [input, setInput] = useState("");
const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents("/api/agent"),
threadId: "support-chat",
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
sendMessage(input);
setInput("");
}}
>
{messages.map((message) =>
message.parts.map((part, index) => {
if (part.type === "text") {
return <p key={`${message.id}-${index}`}>{part.content}</p>;
}
if (part.type === "tool-call") {
return <code key={part.id}>{part.name}</code>;
}
return null;
}),
)}
<input value={input} onChange={(event) => setInput(event.target.value)} />
</form>
);
}Ask it "Fix the failing test in utils.test.ts". You see the agent's text, plus a line for each tool it used. The agent edited the files inside the container.
How it works#
acpCompatible gives you the full ACP flow:
- It finds the sandbox from withSandbox(...) and starts the agent there.
- It bridges your chat() tools into the agent over MCP.
- It handles session resume, permission requests, and abort.
- It turns the ACP updates of the agent into chat() stream chunks.
Configure an agent once#
For more than one model, configure the agent once with acpCompatible. You get a factory. Then pick the model on each call:
// pi-harness.ts
import { acpCompatible } from "@tanstack/ai-acp";
export 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}`
: ""),
authMethodId: "pi-api-key",
refusalMessage: "Pi refused the request.",
});import { chat } from "@tanstack/ai";
import { withSandbox } from "@tanstack/ai-sandbox";
import { pi } from "./pi-harness";
import { sandbox } from "./sandbox";
const stream = chat({
adapter: pi("pi-pro"),
modelOptions: { reasoningEffort: "high" },
messages: [{ role: "user", content: "Fix the failing test." }],
threadId: "thread-1",
middleware: [withSandbox(sandbox)],
});- models makes the factory type-safe. pi("pi-ultra") is a type error. Without models, the factory takes any string.
- modelOptions is a type-only brand. The value {} as { ... } is not used at run time. It types what chat({ modelOptions }) takes.
- Your declared options join the base options. command and openTransport get them as ctx.modelOptions, so you can turn them into CLI flags.
- The base options are always there: sessionId, cwd, authMode, authMethodId, and permissionMode.
Permissions#
Inside a sandbox, the sandbox is the security boundary. So by default, the adapter answers each permission request of the agent with permissionMode: 'bypassPermissions'. The agent edits files and runs commands with no prompt.
To send tool approvals to a client, switch to 'interactive':
import { acpCompatible } from "@tanstack/ai-acp";
export const pi = acpCompatible({
name: "pi",
command: ({ model }) => `pi --acp -m ${model}`,
permissions: "interactive", // send approval-requested events for `ask` prompts
permissionMode: "acceptEdits", // still allow file edits with no prompt
});- With 'interactive', an ask prompt sends an approval-requested event. The client approves, then runs the turn again.
- Your chat() tools that the adapter bridges into the agent are always allowed, in every mode.
- To write your own rules, pass onPermissionRequest. It overrides permissions and permissionMode.
Tool choice#
You want the agent to see only one of your tools, or none of them. Pass toolChoice to chat(). It limits only the tools that the adapter bridges into the agent. ACP has no field to turn off the built-in tools of the agent or to force a tool call. So the agent decides when it calls a tool.
| Value | What the adapter does |
|---|---|
| 'auto' | Bridges all of your tools. |
| 'none' | Bridges none of your tools. |
| { type: 'tool', name } | Bridges only the named tool. The agent does not have to call it. |
| 'required' | Bridges all of your tools. The agent does not have to call one. |
Each value except 'auto' logs a warning the first time an adapter instance gets it. The warning starts with the harness name, for example pi:.
For the values on other adapters, see Choose when the model calls a tool.
Session resume#
On each run, the adapter sends the session id of the agent as a custom event named <name>.session-id, for example pi.session-id. Send the id back in modelOptions.sessionId on the next call, and the agent continues the session:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { withSandbox } from "@tanstack/ai-sandbox";
import { pi } from "./pi-harness";
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,
threadId: params.threadId,
middleware: [withSandbox(sandbox)],
modelOptions: { sessionId },
});
return toServerSentEventsResponse(stream);
}On a resumed session, the adapter sends only the last user message. The agent already has the rest. For the client half, see Resume a session on the Claude Code page. Use your <name>.session-id as the event name.
Typed output#
You want a typed object after the agent works. Pass outputSchema on the same chat() call. The agent runs its own tools. Then the adapter adds the schema to the prompt and parses the last assistant text as JSON.
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { acpCompatibleText } from "@tanstack/ai-acp";
import { withSandbox } from "@tanstack/ai-sandbox";
import { z } from "zod";
import { sandbox } from "./sandbox";
const ReportSchema = z.object({
name: z.string(),
oneLiner: z.string(),
});
export async function POST(request: Request) {
const { messages, threadId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: acpCompatibleText("pi-fast", {
name: "pi",
command: ({ model }) => `pi --acp -m ${model}`,
}),
messages,
threadId,
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(),
});
export function Report() {
const { messages, final } = useChat({
connection: fetchServerSentEvents("/api/report"),
threadId: "support-chat",
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}
</>
);
}You can also read the object from await chat() when you do not stream. See Harness Agents for the full part list and the repo-report example.
Models and options#
acpCompatible has no built-in model list. The model is any string that your agent takes, or one of the models that you declare.
Configuration#
Pass these to acpCompatible or as the second argument of acpCompatibleText:
| Field | What it does |
|---|---|
| name (required) | The harness label, the log prefix, and the name of the <name>.session-id custom event. |
| command | Builds the stdio launch command from { model, cwd, harnessCwd, sandbox, env, modelOptions, signal }. Required unless you pass openTransport. |
| openTransport | Opens any AcpSessionTransport yourself, for example a WebSocket. It overrides command. See WebSocket and custom transports. |
| models | The model ids that the harness takes. With this list, unknown ids are a type error. |
| modelOptions | Type-only brand for the per-call options of chat({ modelOptions }). Declare it with {} as { ... }. |
| cwd | The working directory inside the sandbox. Default /workspace. |
| env | More environment variables for the agent process. |
| authMode | 'api-key' (default) uses authMethodId. 'host' skips ACP authenticate. See Harness Auth. |
| authMethodId | The ACP auth method to select before the session starts. Ignored when authMode is 'host'. |
| permissionMode | 'default', 'acceptEdits', or 'bypassPermissions' (default). |
| permissions | 'headless' (default) answers each request. 'interactive' sends approval-requested events for ask prompts. |
| onPermissionRequest | Your own permission handler. It overrides permissions and permissionMode. |
More options, for special cases:
| Field | What it does |
|---|---|
| skillsDir | The skills directory of the agent, relative to the workspace root, for example '.pi/skills'. Workspace gitSkills are linked here. Without it, they stay unlinked and the adapter logs a warning. |
| refusalMessage | The RUN_ERROR message when the agent refuses a request. |
| planEventName | Send ACP plan updates as a custom event with this name. |
| emitDiff | Send the git diff of cwd after the run as a file.changed custom event. Off by default. Needs a git repo at cwd. |
| onExtNotification | Handles vendor _x/... JSON-RPC notifications. It must not throw. |
| buildPrompt | Changes how the chat history becomes the prompt of the agent. |
This adapter ignores wrapFetch, because the agent process sends the model requests.
Advanced#
WebSocket and custom transports#
Some agents run an ACP server that you reach over WebSocket, not stdio (the grok agent serve pattern). Open the transport yourself with openTransport. It gets the same context as command and returns an AcpSessionTransport. Put all teardown in the dispose of the transport:
import { acpCompatible, startAcpServerInSandbox } from "@tanstack/ai-acp";
export 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();
},
};
},
});Workspace skills#
When you set up a workspace with withSandbox, acpCompatible puts its skills where the agent expects them:
| Workspace input | What acpCompatible does |
|---|---|
| mcpSkill(name, config) | Passes the server to the agent over ACP, in the mcpServers of newSession, with secrets and bearer headers resolved. No config file is needed. |
| gitSkill({ repo }) | Clones the repo during bootstrap, then links it into your skillsDir. Without skillsDir, it stays unlinked and the adapter logs a warning. |
| fileSkill({ path, content }) | Writes the file into the workspace root during bootstrap. |
| instructions | Writes them to AGENTS.md (and symlinks) during bootstrap. |
| agentSkill(name), plugins | Skipped with a warning, because ACP has no generic way to pass them. Use a gitSkill or an MCP server. |
The workspace secrets go into the environment of the agent on create and on resume. They are never saved to snapshots. The agent CLI reads them like any environment variable.
Protocol coverage#
acpCompatible implements the client side of ACP: enough to drive an agent through a full prompt turn. It is a minimal client that follows the spec. Each part it does not implement is either gated by a capability, so it advertises no support, or a rendering choice.
It covers:
- The initialize handshake. It sends clientInfo and the protocol version, agrees on the version, and advertises capabilities.
- authenticate (when the agent advertises auth methods), session/new, session/load (resume), session/prompt, and session/cancel.
- session/request_permission with all four option kinds, mapped by the permission mode.
- The streamed session/updates that carry turn output: agent_message_chunk, agent_thought_chunk (as reasoning), tool_call, tool_call_update, and plan.
- All five stop reasons: end_turn, max_tokens, max_turn_requests, refusal, and cancelled.
Some output arrives as CUSTOM stream events, because the AG-UI chat events have no event for non-text assistant output:
- <name>.session-id: the session id of the agent, for resume.
- <name>.message-content: non-text agent content (image, audio, resource, or resource_link blocks). Its value is { content: <ACP content block> }. Non-text tool content (diffs, terminal output, images) stays inside the TOOL_CALL_RESULT payload.
- The plan event, when you set planEventName.
It does not implement:
- fs/read_text_file, fs/write_text_file, and terminal/*. It advertises them as unsupported. The agent runs inside the sandbox with direct file and shell access, so it never sends these back to the client.
- Multimodal prompts. Prompts go to the agent as text. Multimodal output of the agent arrives as message-content, shown above.
- Incremental usage_update (the final usage of the turn arrives instead), available_commands_update, current_mode_update, and the experimental features (elicitation, NES, providers, session modes and config).
To find agents, see the ACP agents list. For how harnesses fit with sandboxes, see Harnesses.