For two years, building a coding agent meant writing the loop yourself: call the model, parse tool calls, execute them, feed results back, repeat until done. Everyone rebuilt the same machinery, and everyone got the hard parts subtly wrong — context compaction, tool error recovery, permission boundaries, resumption after a crash.
AI SDK 7 takes a different position. Instead of giving you better primitives to build a loop, it gives you the loops that already work. HarnessAgent wraps established agent harnesses — Claude Code, Codex, Pi — behind one TypeScript API, then lets you configure what they can touch: which sandbox they run in, which skills they load, which tools require human approval.
This tutorial builds a real GitHub pull-request review agent using that stack. By the end you will have an agent that clones a repository into an isolated sandbox, reviews a PR using a skill you authored, asks permission before posting anything public, and survives a server restart mid-run.
Why harnesses instead of your own loop? A harness is the accumulated tuning of a production coding agent — prompt structure, context management, tool schemas, retry behaviour. HarnessAgent lets you inherit that work and spend your effort on the part that is actually yours: the domain logic, the guardrails, and the integration surface.
Prerequisites
Before starting, make sure you have:
- Node.js 20 or newer (Node 24 recommended — the sandbox runtime targets it)
- TypeScript 5.5+ and familiarity with async iteration
- A Vercel account if you want to use hosted sandboxes (local sandboxes work without one)
- An API key for at least one provider — Anthropic for Claude Code, OpenAI for Codex
- A GitHub personal access token with
reposcope for the review example - Working knowledge of the AI SDK's
tool()helper — if you are new to it, start with our AI SDK 5 streaming agents tutorial
What You'll Build
A command-line PR review agent that:
- Accepts a GitHub repository and pull-request number
- Spins up an isolated sandbox with the repository checked out
- Runs Claude Code inside that sandbox with a custom review skill
- Reads the PR diff and linked issues through typed tools
- Requires explicit approval before posting a review comment
- Persists its state so an interrupted run resumes instead of restarting
We will build it in layers, running the thing at every step.
Step 1: Project Setup
Create the project and install the AI SDK 7 packages:
mkdir pr-review-agent && cd pr-review-agent
pnpm init
pnpm add ai@latest zod
pnpm add @ai-sdk/anthropic @ai-sdk/openai
pnpm add @ai-sdk/sandbox @ai-sdk/workflow @ai-sdk/tui
pnpm add -D typescript tsx @types/nodeA minimal tsconfig.json:
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"]
}Set "type": "module" in package.json, then create your environment file:
# .env
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
GITHUB_TOKEN=ghp_...
VERCEL_OIDC_TOKEN=... # only needed for hosted sandboxesNever commit this file. Add .env to .gitignore before your first commit. Agents that run shell commands make leaked credentials significantly more dangerous than usual — a sandbox with your production token is a sandbox in name only.
Step 2: Your First HarnessAgent
Start with the smallest thing that runs. Create src/hello-agent.ts:
import 'dotenv/config';
import { HarnessAgent } from 'ai';
import { claudeCode } from 'ai/harnesses';
const agent = new HarnessAgent({
harness: claudeCode,
instructions:
'You are a careful code reviewer. Be concise and specific. ' +
'Prefer concrete line references over general advice.',
});
const result = await agent.generate({
prompt: 'Explain what a race condition is in a Node.js HTTP handler, in three sentences.',
});
console.log(result.text);Run it:
pnpm tsx src/hello-agent.tsThree things are worth noticing. You never wrote a loop — HarnessAgent owns it. You never described the model's tool-calling format — the harness ships with its own. And instructions is layered on top of the harness's own system prompt rather than replacing it, so you steer behaviour without discarding the tuning you came for.
Swapping harnesses is a one-line change:
import { codex } from 'ai/harnesses';
const agent = new HarnessAgent({
harness: codex, // [!code highlight]
instructions: 'You are a careful code reviewer...',
});Step 3: Add a Sandbox
Right now the agent has no execution environment. Give it one. A sandbox is an isolated filesystem and process space where the agent can run shell commands without touching your machine.
Create src/sandboxed-agent.ts:
import 'dotenv/config';
import { HarnessAgent } from 'ai';
import { claudeCode } from 'ai/harnesses';
import { createVercelSandbox } from '@ai-sdk/sandbox';
const agent = new HarnessAgent({
harness: claudeCode,
sandbox: createVercelSandbox({
runtime: 'node24',
ports: [4000],
timeoutMs: 10 * 60 * 1000,
}),
instructions:
'You have a sandbox with Node 24. Use shell commands to inspect and test code. ' +
'Always run the test suite before concluding that a change is safe.',
});
const result = await agent.generate({
prompt:
'Create a small Node script that computes the 40th Fibonacci number ' +
'both recursively and iteratively, then benchmark both and report the difference.',
});
console.log(result.text);The agent now writes files, runs node, reads the output, and reasons about it — all inside a container that disappears when the run ends.
For local development you can avoid the hosted round trip:
import { createLocalSandbox } from '@ai-sdk/sandbox';
const sandbox = createLocalSandbox({
cwd: './workspace',
allowedCommands: ['node', 'npm', 'pnpm', 'git', 'ls', 'cat', 'grep'],
});allowedCommands is a real boundary, not a suggestion. A local sandbox shares your filesystem. Without an allowlist, "run the tests" and "delete the repository" are the same category of action to the agent. In production, prefer a hosted sandbox — the isolation is enforced by the container, not by a string comparison.
Step 4: Package Domain Knowledge as a Skill
Instructions get unwieldy fast. Once your prompt starts containing checklists and house conventions, move it into a skill — a named, described, reusable block of instruction the harness loads on demand.
Create src/skills/review-pr.ts:
export const reviewPullRequestSkill = {
name: 'review-github-pr',
description:
'Reviews a GitHub pull request against the team engineering standards. ' +
'Use whenever the user asks for a PR review, code review, or diff assessment.',
content: `
# Pull Request Review
## Procedure
1. Read the PR title, body, and linked issues to establish intent.
2. Read the full diff before commenting on any single file.
3. Check the diff against the review checklist below.
4. Verify claims by running code in the sandbox — never assert that a test
passes without running it.
5. Produce the output in the required format.
## Review checklist
- **Correctness:** off-by-one errors, unhandled null and undefined,
incorrect async ordering, missing await.
- **Error handling:** every catch block either handles or rethrows.
Empty catch blocks are always a finding.
- **Security:** unvalidated input reaching a query, a shell command,
or a filesystem path. Secrets in source.
- **Tests:** does each behavioural change have a corresponding test?
- **Scope:** does the diff do anything the PR description does not mention?
## Output format
For each finding emit exactly:
**[severity] file:line** — one-sentence description of the defect.
Then a concrete failure scenario: specific inputs leading to specific wrong output.
Severity is one of: blocker, major, minor, nit.
If you find nothing, say so plainly. Do not invent findings to appear thorough.
`,
};Attach it:
const agent = new HarnessAgent({
harness: claudeCode,
sandbox: createVercelSandbox({ runtime: 'node24' }),
skills: [reviewPullRequestSkill], // [!code highlight]
instructions: 'You are a senior engineer reviewing code for a TypeScript team.',
});The description field is the load-bearing part. The harness reads descriptions to decide when a skill is relevant, so write it as a trigger condition ("use whenever the user asks for..."), not as a summary.
For harnesses that run in provider-managed containers, upload the skill once and reference it by handle instead of resending the content on every call:
import { uploadSkill } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { readFileSync } from 'node:fs';
const { providerReference } = await uploadSkill({
api: anthropic.skills(),
files: [
{ path: 'review-pr/SKILL.md', content: readFileSync('./skills/review-pr/SKILL.md') },
],
displayTitle: 'PR Review Standards',
});Step 5: Give the Agent Typed Tools
Skills tell the agent how to think. Tools give it reach. Our agent needs GitHub access, so define tools with Zod schemas.
Create src/tools/github.ts:
import { tool } from 'ai';
import { z } from 'zod';
const GITHUB_API = 'https://api.github.com';
async function gh(path: string, token: string, init?: RequestInit) {
const response = await fetch(`${GITHUB_API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...init?.headers,
},
});
if (!response.ok) {
throw new Error(`GitHub ${response.status}: ${await response.text()}`);
}
return response;
}
export const readPullRequest = tool({
description: 'Fetch a pull request title, body, author, and unified diff.',
inputSchema: z.object({
owner: z.string().describe('Repository owner, e.g. "vercel"'),
repo: z.string().describe('Repository name, e.g. "ai"'),
number: z.number().int().positive().describe('Pull request number'),
}),
contextSchema: z.object({ token: z.string() }),
execute: async ({ owner, repo, number }, { context: { token } }) => {
const meta = await gh(`/repos/${owner}/${repo}/pulls/${number}`, token).then((r) => r.json());
const diff = await gh(`/repos/${owner}/${repo}/pulls/${number}`, token, {
headers: { Accept: 'application/vnd.github.v3.diff' },
}).then((r) => r.text());
return {
title: meta.title,
body: meta.body ?? '',
author: meta.user?.login,
changedFiles: meta.changed_files,
additions: meta.additions,
deletions: meta.deletions,
diff: diff.slice(0, 120_000),
};
},
});
export const postReviewComment = tool({
description:
'Post a review comment on a pull request. This is publicly visible and cannot be undone.',
inputSchema: z.object({
owner: z.string(),
repo: z.string(),
number: z.number().int().positive(),
body: z.string().min(1).describe('Markdown body of the review comment'),
}),
contextSchema: z.object({ token: z.string() }),
execute: async ({ owner, repo, number, body }, { context: { token } }) => {
const result = await gh(`/repos/${owner}/${repo}/issues/${number}/comments`, token, {
method: 'POST',
body: JSON.stringify({ body }),
}).then((r) => r.json());
return { url: result.html_url, id: result.id };
},
});Two details matter here.
contextSchema keeps the GitHub token out of the model's input entirely. The agent chooses which PR to read; the runtime supplies the credential. The model never sees the token and therefore cannot leak it into a response, a log line, or a file it writes in the sandbox.
The diff.slice(0, 120_000) cap is deliberate. A large PR can produce a diff that consumes the entire context window and leaves no room for reasoning. Truncating at a known boundary fails predictably instead of mysteriously.
Step 6: Gate Dangerous Tools Behind Approval
readPullRequest is harmless. postReviewComment writes something public under your name. AI SDK 7 lets you mark that distinction declaratively:
import { HarnessAgent } from 'ai';
import { claudeCode } from 'ai/harnesses';
import { createVercelSandbox } from '@ai-sdk/sandbox';
import { readPullRequest, postReviewComment } from './tools/github.js';
import { reviewPullRequestSkill } from './skills/review-pr.js';
export const reviewAgent = new HarnessAgent({
harness: claudeCode,
sandbox: createVercelSandbox({ runtime: 'node24', timeoutMs: 15 * 60 * 1000 }),
skills: [reviewPullRequestSkill],
tools: { readPullRequest, postReviewComment },
toolApproval: {
postReviewComment: 'user-approval', // [!code highlight]
},
toolContext: { token: process.env.GITHUB_TOKEN! },
instructions:
'You are a senior engineer reviewing code for a TypeScript team. ' +
'Review thoroughly before posting anything. Post at most one comment per run.',
});When the agent calls postReviewComment, the run suspends and surfaces an approval request instead of executing. Handle it by iterating the stream:
const stream = await reviewAgent.stream({
prompt: 'Review pull request 412 in vercel/ai and post your findings.',
});
for await (const part of stream.fullStream) {
if (part.type === 'tool-approval-request') {
console.log(`\nAgent wants to call: ${part.toolName}`);
console.log(JSON.stringify(part.input, null, 2));
const approved = await askUser('Approve? (y/n) ');
await stream.respondToApproval({ id: part.id, approved });
}
if (part.type === 'text-delta') process.stdout.write(part.text);
}You can also express the policy as a function when the decision depends on the arguments rather than the tool identity:
toolApproval: {
postReviewComment: async ({ input }) =>
input.body.length > 2000 ? 'user-approval' : 'auto-approve',
}Approval is a boundary, not a UX nicety. The rule that holds up in production: any tool whose effect is visible outside your process — posting, emailing, deploying, paying, deleting — is approval-gated by default. Read-only tools run free. This single classification prevents most of the failure modes that make teams distrust agents.
Step 7: Add Timeouts and Observability
An agent that hangs is worse than one that fails. AI SDK 7 exposes layered timeouts:
const result = await reviewAgent.generate({
prompt: 'Review pull request 412 in vercel/ai.',
timeout: {
totalMs: 15 * 60 * 1000, // whole run
stepMs: 90_000, // any single step
chunkMs: 20_000, // gap between stream chunks
toolMs: 30_000, // default per tool call
tools: {
readPullRequestMs: 45_000, // large diffs need longer
},
},
});chunkMs is the one people skip and later wish they had not — it catches a stalled provider connection that would otherwise sit open until totalMs expires.
For tracing, register telemetry once at startup:
import { registerTelemetry } from 'ai';
import { OpenTelemetry } from '@ai-sdk/opentelemetry';
registerTelemetry(new OpenTelemetry());Then read per-run metrics off the final step:
const { performance } = await result.finalStep;
console.log({
responseTimeMs: performance.responseTimeMs,
outputTokensPerSecond: performance.outputTokensPerSecond,
timeToFirstOutputMs: performance.timeToFirstOutputMs,
totalTokens: result.usage.totalTokens,
});If you already run an observability stack, our Langfuse LLM observability tutorial shows how to route these traces into a dashboard with prompt-level cost attribution.
Step 8: Make Runs Durable
A PR review can run for ten minutes. Deployments, restarts, and scale-to-zero all happen inside that window. WorkflowAgent persists agent state between steps so an interrupted run resumes from its last checkpoint.
import { WorkflowAgent } from '@ai-sdk/workflow';
import { claudeCode } from 'ai/harnesses';
import { createVercelSandbox } from '@ai-sdk/sandbox';
import { readPullRequest, postReviewComment } from './tools/github.js';
import { reviewPullRequestSkill } from './skills/review-pr.js';
export const durableReviewAgent = new WorkflowAgent({
harness: claudeCode,
sandbox: createVercelSandbox({ runtime: 'node24' }),
skills: [reviewPullRequestSkill],
tools: { readPullRequest, postReviewComment },
toolApproval: { postReviewComment: 'user-approval' },
toolContext: { token: process.env.GITHUB_TOKEN! },
workflowId: (input) => `pr-review-${input.owner}-${input.repo}-${input.number}`,
});A deterministic workflowId gives you idempotency for free: re-triggering the same PR resumes the existing run rather than starting a duplicate review.
Wire it to a webhook so reviews start on PR events:
// app/api/github-webhook/route.ts
import { durableReviewAgent } from '@/lib/review-agent';
export async function POST(req: Request) {
const event = await req.json();
if (event.action !== 'opened' && event.action !== 'synchronize') {
return new Response('ignored', { status: 200 });
}
await durableReviewAgent.trigger({
owner: event.repository.owner.login,
repo: event.repository.name,
number: event.pull_request.number,
prompt: `Review pull request ${event.pull_request.number}.`,
});
return new Response('queued', { status: 202 });
}Because trigger returns as soon as the run is durably enqueued, the webhook responds well inside GitHub's timeout while the agent keeps working.
Step 9: Drive It From a Terminal UI
For local iteration, @ai-sdk/tui gives you an interactive session in a few lines:
// src/cli.ts
import 'dotenv/config';
import { runAgentTUI } from '@ai-sdk/tui';
import { reviewAgent } from './review-agent.js';
await runAgentTUI({
agent: reviewAgent,
title: 'PR Review Agent',
onApprovalRequest: async ({ toolName, input }) => ({
approved: await confirmInTerminal(`Call ${toolName}?`, input),
}),
});pnpm tsx src/cli.tsThe TUI renders reasoning, tool calls, and markdown as formatted text, and handles approval prompts inline. It is the fastest way to watch what your agent is actually doing before you put it behind a webhook.
Testing Your Implementation
Verify each layer independently:
Harness responds. Run src/hello-agent.ts. You should get three sentences with no tool calls.
Sandbox executes. Run src/sandboxed-agent.ts and confirm the output contains real benchmark numbers. If the agent reports timings without having run anything, the sandbox is not attached — check that createVercelSandbox is passed and your token is valid.
Skill loads. Ask for a PR review and confirm the output follows your **[severity] file:line** format. If it does not, the skill description is not matching — make it more explicitly a trigger condition.
Approval blocks. Ask the agent to post a comment and answer n. Verify via the GitHub API that no comment exists:
curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/OWNER/REPO/issues/412/comments" | jq 'length'Durability holds. Trigger a durable run, kill the process after the first tool call, restart, and re-trigger with the same workflowId. The run should resume rather than re-reading the diff from scratch.
Troubleshooting
Sandbox timed out on large repositories. The default budget is often too small for a full install. Raise timeoutMs on the sandbox and give readPullRequest its own longer toolMs. If installs dominate, pre-warm the sandbox image with dependencies baked in.
Agent ignores the skill. Almost always a description problem. "Reviews pull requests" is a summary; "Use whenever the user asks for a PR review, code review, or diff assessment" is a trigger. Write triggers.
Approval requests never arrive. generate() cannot surface them — it resolves only when the run finishes. Approvals require stream() and iterating fullStream, or the TUI's onApprovalRequest callback.
Context window exhausted on big diffs. Lower the diff.slice cap and add a tool that fetches a single file's patch on demand. Letting the agent pull files selectively beats forcing the whole diff through the window.
Migration errors from AI SDK 6. Run the codemod rather than hand-editing:
npx @ai-sdk/codemod v7The main manual change is Agent becoming ToolLoopAgent. If your agent used a hand-rolled loop around generateText, the codemod leaves it alone — that migration is yours to make deliberately.
Next Steps
- Broaden the toolset. Add tools for CI status, linked issues, and previous review threads so the agent reasons about history, not just the current diff.
- Compose harnesses. Run Claude Code and Codex over the same PR and reconcile their findings — disagreement between two harnesses is a strong signal of genuine ambiguity in the code.
- Add evaluations. Build a fixture set of PRs with known defects and measure detection rate as you tune skills. Our Promptfoo evals tutorial covers the harness for that.
- Persist findings. Write reviews to a database so you can track which categories of defect your team ships most often.
- Extend to MCP. Expose your tools over Model Context Protocol so the same capabilities work in Cursor and Claude Desktop — see our MCP server tutorial.
Conclusion
The shift in AI SDK 7 is one of altitude. You are no longer assembling an agent from primitives; you are configuring an environment for an agent that already works. The pieces you supply are the ones only you can supply: the sandbox boundary, the domain knowledge encoded as a skill, the typed tools that reach your systems, and the approval policy that decides which actions a machine may take unsupervised.
That last piece is the one worth dwelling on. A coding agent with sandbox isolation, credential scoping through contextSchema, and approval gates on every externally visible action is not a demo — it is a system you can point at a real repository. The layers you built here, in order, are exactly the layers that make that true.
Start with the read-only path. Get the review quality where you want it. Only then hand the agent a token that can write.