The OpenCode adapter starts opencode serve in a sandbox and streams text and tool steps to chat(). The agent edits files and runs commands in a repo.
You need:
npm i @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-dockerpnpm add @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-dockeryarn add @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-dockerbun add @tanstack/ai-opencode @tanstack/ai-sandbox @tanstack/ai-sandbox-dockerPut the key in the environment of your server:
OPENAI_API_KEY=sk-...OpenCode reads the key from the environment of its process. The sandbox in step 3 passes it in as a workspace secret. This adapter has no authMode option.
Define the sandbox once. It clones your repo into a Docker container and installs the opencode CLI:
// sandbox.ts
import {
createSecrets,
defineSandbox,
defineWorkspace,
githubRepo,
} from "@tanstack/ai-sandbox";
import { dockerSandbox } from "@tanstack/ai-sandbox-docker";
export const sandbox = defineSandbox({
id: "opencode-agent",
// The host talks to `opencode serve` on port 4096, so publish it.
provider: dockerSandbox({ image: "node:22", publishPorts: [4096] }),
workspace: defineWorkspace({
source: githubRepo({ repo: "owner/app" }),
setup: ["npm install -g opencode-ai --include=optional && opencode --version"],
secrets: createSecrets({
OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? "",
}),
}),
});Then pass the adapter and the sandbox to chat() in your route:
// api/agent.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { opencodeText } from "@tanstack/ai-opencode";
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: opencodeText("openai/gpt-5.2", {
permissionMode: "acceptEdits",
}),
messages,
threadId,
runId,
middleware: [withSandbox(sandbox)],
});
return toServerSentEventsResponse(stream);
}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". The text streams token by token, and you see a line for each tool the agent used: read, edit, bash, and more. The agent edited the files inside the container.
To learn more about sandboxes, see Sandboxes.
OpenCode asks for permission before it edits files or runs commands. A headless server has no person to answer, so the adapter answers each request from permissionMode. A turn never waits for an answer.
| Mode | What runs |
|---|---|
| 'default' | Your bridged tools run. Every other request that asks (edits, shell, web fetch) is rejected. |
| 'acceptEdits' | Also allows file edits (edit, write, patch). |
| 'bypassPermissions' | Allows every request. Use it only in a sandbox or a scratch directory. |
To write your own rules, for example to allow only some commands, pass onPermissionRequest. It replaces the default policy.
OpenCode keeps its working context between turns: the files it read, the commands it ran, and what it found. The adapter sends the session id of each new run as a custom event named opencode.session-id. Send it back in modelOptions.sessionId to continue the session.
On the server, read the id from the request:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { opencodeText } from "@tanstack/ai-opencode";
import { withSandbox } from "@tanstack/ai-sandbox";
import { sandbox } from "./sandbox";
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request);
// Extra fields the client puts in the connection `body` arrive here.
const sessionId =
typeof params.forwardedProps.sessionId === "string"
? params.forwardedProps.sessionId
: undefined;
const stream = chat({
adapter: opencodeText("openai/gpt-5.2", { permissionMode: "acceptEdits" }),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
middleware: [withSandbox(sandbox)],
modelOptions: { sessionId },
});
return toServerSentEventsResponse(stream);
}On the client, keep the id from the custom event and send it with each request:
import { useState } from "react";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
export function useCodingAgent() {
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
return useChat({
connection: fetchServerSentEvents("/api/agent", () => ({
body: { sessionId },
})),
threadId: "support-chat",
onCustomEvent: (name, value) => {
if (
name === "opencode.session-id" &&
typeof value === "object" &&
value !== null &&
"sessionId" in value &&
typeof value.sessionId === "string"
) {
setSessionId(value.sessionId);
}
},
});
}The agent has its own tools for files and the shell. It cannot reach your database or your secrets. Pass your server tools to chat(), and the adapter bridges them into the sandbox:
import { z } from "zod";
import { chat, toolDefinition } from "@tanstack/ai";
import { opencodeText } from "@tanstack/ai-opencode";
import { withSandbox } from "@tanstack/ai-sandbox";
import { sandbox } from "./sandbox";
const lookupTicket = toolDefinition({
name: "lookup_ticket",
description: "Look up an issue ticket by id",
inputSchema: z.object({ ticketId: z.string() }),
}).server(async ({ ticketId }) => {
return { ticketId, status: "open", title: "Crash on startup" };
});
const stream = chat({
adapter: opencodeText("openai/gpt-5.2"),
messages: [{ role: "user", content: "What's the status of ticket T-123?" }],
threadId: "thread-1",
tools: [lookupTicket],
middleware: [withSandbox(sandbox)],
});For how the bridge reaches your server from each provider, see Sandbox Tools.
You want OpenCode to see only one of your tools, or none of them. Pass toolChoice to chat():
const onlyLookup = chat({
adapter: opencodeText("openai/gpt-5.2"),
messages: [{ role: "user", content: "What's the status of ticket T-123?" }],
threadId: "thread-1",
tools: [lookupTicket],
toolChoice: { type: "tool", name: "lookup_ticket" },
middleware: [withSandbox(sandbox)],
});toolChoice limits only the tools that the adapter bridges into OpenCode. The built-in OpenCode tools (bash, edit, and the others) always stay on, and OpenCode 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. OpenCode does not have to call it. |
| 'required' | Bridges all of your tools. OpenCode does not have to call one. |
Each value except 'auto' logs a warning the first time an adapter instance gets it.
For the values on other adapters, see Choose when the model calls a tool.
You want a typed report after the agent reads the repo. Pass outputSchema on the same chat() call:
import { chat } from "@tanstack/ai";
import { opencodeText } from "@tanstack/ai-opencode";
import { withSandbox } from "@tanstack/ai-sandbox";
import { z } from "zod";
import { sandbox } from "./sandbox";
const Report = z.object({
summary: z.string(),
filesChanged: z.array(z.string()),
});
const report = await chat({
adapter: opencodeText("openai/gpt-5.2"),
messages: [{ role: "user", content: "Review this repo." }],
threadId: "thread-1",
outputSchema: Report,
middleware: [withSandbox(sandbox)],
});
console.log(report.summary);On the client, pass the same schema to useChat and read final. partial stays empty until the end.
import { fetchServerSentEvents, useChat } from "@tanstack/ai-react";
import { z } from "zod";
const Report = z.object({
summary: z.string(),
filesChanged: z.array(z.string()),
});
export function ReportView() {
const { final, isLoading } = useChat({
connection: fetchServerSentEvents("/api/repo-report"),
threadId: "support-chat",
outputSchema: Report,
});
if (isLoading) return <p>The agent is inspecting the repo.</p>;
if (!final) return null;
return <p>{final.summary}</p>;
}Do you only need JSON from a prompt, with no repo? Use a model adapter such as @tanstack/ai-openai. For the full walkthrough, see Harness Agents.
| Model | Reasoning levels | What reasoning sets |
|---|---|---|
| anthropic/claude-opus-4-5 | low, medium, high | thinking with a token budget |
| anthropic/claude-sonnet-4-5, opencode/claude-sonnet-4-5 | off, minimal, low, medium, high | thinking with a token budget |
| openai/gpt-5.2 | off, low, medium, high, xhigh | reasoningEffort |
| openai/gpt-5.1-codex, opencode/gpt-5.1-codex | low, medium, high | reasoningEffort |
| google/gemini-3-pro-preview | none | none |
The package exports this list as OPENCODE_MODELS. The adapter also takes any other provider/model id that your OpenCode providers support.
Set reasoning on chat(). The adapter writes it into the options of the model in the OpenCode server config:
import { chat } from "@tanstack/ai";
import { opencodeText } from "@tanstack/ai-opencode";
import { withSandbox } from "@tanstack/ai-sandbox";
import { sandbox } from "./sandbox";
const stream = chat({
adapter: opencodeText("anthropic/claude-sonnet-4-5"),
messages: [{ role: "user", content: "Refactor the auth module" }],
threadId: "thread-1",
reasoning: { level: "high", budgetTokens: 8000 },
middleware: [withSandbox(sandbox)],
});See Reasoning.
Pass these as the second argument of opencodeText:
| Option | What it does |
|---|---|
| directory | The working directory inside the sandbox. Default /workspace. |
| port | The port of opencode serve inside the sandbox. Default 4096. On Docker, publish it with publishPorts. |
| hostname | The hostname that the server binds in the sandbox. Default 0.0.0.0. |
| permissionMode | 'default' (default), 'acceptEdits', or 'bypassPermissions'. See Permissions. |
| onPermissionRequest | Your own permission handler. It replaces the default policy. |
Pass these in modelOptions on chat():
The host connects to opencode serve over HTTP, so the sandbox must expose the port:
OpenCode does not write NDJSON to stdout, so this adapter writes no run journal, also on a durable run. See Harnesses.