The Claude Code adapter runs the claude CLI in a sandbox and streams each step to chat(). The agent reads files, edits them, and runs commands in a repo.
You need:
Do you only want a Claude chat, with no repo and no shell? Use the Anthropic adapter.
npm i @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-dockerpnpm add @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-dockeryarn add @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-dockerbun add @tanstack/ai-claude-code @tanstack/ai-sandbox @tanstack/ai-sandbox-dockerPut the key in the environment of your server:
ANTHROPIC_API_KEY=sk-ant-...The sandbox in step 3 gives this key to the claude process as a workspace secret.
Did the machine already run claude login? Then you can use that login and no key. See Harness Auth.
Define the sandbox once. It clones your repo into a Docker container and installs the claude CLI:
// sandbox.ts
import {
createSecrets,
defineSandbox,
defineWorkspace,
githubRepo,
} from "@tanstack/ai-sandbox";
import { dockerSandbox } from "@tanstack/ai-sandbox-docker";
export const sandbox = defineSandbox({
id: "claude-code-agent",
provider: dockerSandbox({ image: "node:22" }),
workspace: defineWorkspace({
source: githubRepo({ repo: "owner/app" }),
setup: [
"npm install -g @anthropic-ai/claude-code --include=optional && claude --version",
],
secrets: createSecrets({
ANTHROPIC_API_KEY: process.env.ANTHROPIC_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 { claudeCodeText } from "@tanstack/ai-claude-code";
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: claudeCodeText("claude-opus-4-8"),
messages,
threadId,
runId,
middleware: [withSandbox(sandbox)],
});
return toServerSentEventsResponse(stream);
}Replace owner/app with your repo.
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: Read, Edit, Bash, and more. The agent edited the files inside the container.
To learn more about sandboxes, see Sandboxes.
Claude Code 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 run as a custom event named claude-code.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 { claudeCodeText } from "@tanstack/ai-claude-code";
import { withSandbox } from "@tanstack/ai-sandbox";
import { sandbox } from "./sandbox";
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request);
// The client sends the id in `forwardedProps`.
const sessionId =
typeof params.forwardedProps.sessionId === "string"
? params.forwardedProps.sessionId
: undefined;
const stream = chat({
adapter: claudeCodeText("claude-opus-4-8"),
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 pass it to forwardedProps. useChat sends the latest value 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"),
forwardedProps: { sessionId },
threadId: "support-chat",
onCustomEvent: (name, value) => {
if (
name === "claude-code.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 { claudeCodeText } from "@tanstack/ai-claude-code";
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: claudeCodeText("claude-opus-4-8"),
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 Claude Code to answer in text only, or to use only one of your tools. Pass toolChoice to chat():
const onlyLookup = chat({
adapter: claudeCodeText("claude-opus-4-8"),
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)],
});| Value | What the adapter does |
|---|---|
| 'auto' | No change. Claude Code can call every tool. |
| 'none' | Turns off the built-in tools and bridges none of your tools. Claude Code answers in text. |
| { type: 'tool', name } | Turns off the built-in tools and bridges only the named tool. Claude Code can call only this tool, but it does not have to. |
| 'required' | No change. Claude Code cannot force a tool call, so the adapter logs a warning the first time. |
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 { claudeCodeText } from "@tanstack/ai-claude-code";
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: claudeCodeText("claude-opus-4-8"),
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 @tanstack/ai-anthropic. That is faster. For the full walkthrough, see Harness Agents.
| Model | Reasoning levels | What reasoning sets |
|---|---|---|
| claude-opus-4-8, claude-opus-4-7, opus | low, medium, high, xhigh, max | --effort |
| claude-opus-4-6, claude-sonnet-4-6, sonnet | low, medium, high, max | --effort |
| claude-haiku-4-5, haiku | off, minimal, low, medium, high | MAX_THINKING_TOKENS |
The package exports this list as CLAUDE_CODE_MODELS. The adapter also takes any other Claude model id as a string.
Set reasoning on chat():
import { chat } from "@tanstack/ai";
import { claudeCodeText } from "@tanstack/ai-claude-code";
import { withSandbox } from "@tanstack/ai-sandbox";
import { sandbox } from "./sandbox";
const stream = chat({
adapter: claudeCodeText("claude-opus-4-8"),
messages: [{ role: "user", content: "Refactor the auth module" }],
threadId: "thread-1",
reasoning: "xhigh",
middleware: [withSandbox(sandbox)],
});For claude-haiku-4-5, the level picks a token budget. You can also set the budget with budgetTokens. off sends 0. See Reasoning.
Pass these as the second argument of claudeCodeText:
| Option | What it does |
|---|---|
| cwd | The working directory inside the sandbox. Default /workspace. |
| permissionMode | 'default', 'acceptEdits', 'bypassPermissions', or 'plan'. Default 'bypassPermissions'. |
| allowedTools | Built-in tools that the agent can use (--allowedTools), for example ['Read', 'Grep', 'Bash(npm test:*)']. |
| disallowedTools | Built-in tools that the agent cannot use (--disallowedTools). |
| addDirs | More directories that the agent can access (--add-dir). |
| maxTurns | The maximum number of agent turns in one run. |
| settingSources | Which Claude Code settings to load. Default ['project']. See Settings sources. |
| systemPromptMode | 'append' (default) adds your systemPrompts to the Claude Code prompt. 'replace' sends only yours. |
| authMode | 'api-key' (default) gives ANTHROPIC_API_KEY to the process. 'host' uses claude login. See Harness Auth. |
| claudeExecutable | The path or name of the claude executable in the sandbox. Default claude. |
| streamPartials | Stream text and tool arguments token by token. Default true. With false, each arrives whole. |
| env | More environment variables for the claude process. |
| emitDiff | Send the file.changed event with the git diff after the run. Default true. |
Pass these in modelOptions on chat(). They override the adapter options for one call:
A headless run has no person to answer a permission prompt. So the default permissionMode is 'bypassPermissions', and the sandbox is the boundary.
By default, the adapter loads only the project settings (--setting-sources project):
For the same behavior as the CLI, pass settingSources: ['user', 'project', 'local']. To load no settings, pass [].
The adapter does not pass --bare, because that flag ignores a host claude login.
For 'none' and a named tool, the adapter passes --tools "" and --strict-mcp-config. Then other MCP servers, for example from the workspace or a settings file, do not load.
RUN_FINISHED.usage.promptTokens counts the full input, cached tokens included. See Token usage.