From ai-agent-builder
Build multi-agent AI systems with Claude Agent SDK. Use this skill for creating agentic applications with orchestrator patterns, Exa search integration, SSE streaming, and Vercel Sandbox deployment. Supports progressive learning from simple agents to production multi-agent pipelines.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ai-agent-builder:web-builderThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use this skill when helping students build modern web applications with AI capabilities. This skill provides guidance for a unified tech stack optimized for building production-ready agentic applications, from simple single-agent queries to complex multi-agent orchestration with Vercel Sandbox deployment.
Use this skill when helping students build modern web applications with AI capabilities. This skill provides guidance for a unified tech stack optimized for building production-ready agentic applications, from simple single-agent queries to complex multi-agent orchestration with Vercel Sandbox deployment.
ALWAYS use the Claude Agent SDK. NEVER use the standard Anthropic SDK.
| ✅ MUST USE | ❌ DO NOT USE |
|---|---|
@anthropic-ai/claude-agent-sdk | @anthropic-ai/sdk |
import { query } from "@anthropic-ai/claude-agent-sdk" | import Anthropic from "@anthropic-ai/sdk" |
Why this matters:
Quick validation: If you see new Anthropic() or client.messages.create() in the code, STOP - you're using the wrong SDK. Refactor to use query() from the Agent SDK instead.
// ✅ CORRECT - Agent SDK
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({
prompt: "Research this topic",
options: { allowedTools: ["Read", "Glob"], permissionMode: "bypassPermissions" }
})) {
console.log(message);
}
// ❌ WRONG - Standard SDK (DO NOT USE)
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const response = await client.messages.create({ ... }); // NO!
When building web applications with this skill, use the following stack:
| Layer | Technology | Purpose |
|---|---|---|
| Runtime | Node.js | JavaScript runtime |
| Framework | Next.js 14+ (App Router) | Full-stack React framework |
| UI Components | shadcn/ui | Accessible, customizable components |
| Styling | Tailwind CSS | Utility-first CSS |
| Database | Supabase PostgreSQL | Managed PostgreSQL with real-time |
| Authentication | Supabase Auth | User authentication |
| AI | Claude Agent SDK (@anthropic-ai/claude-agent-sdk) | AI agent capabilities |
| AI Tools | Exa (exa-js) | Neural web search for agents |
| Agent Runtime | Vercel Sandbox (@vercel/sandbox) | Isolated container for production agents |
| Deployment | Vercel | Hosting and edge functions |
| Language | TypeScript | Type safety |
When creating a new project, guide students through these steps:
Create Next.js project with TypeScript:
npx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
Install shadcn/ui:
npx shadcn@latest init
Install Supabase:
npm install @supabase/supabase-js @supabase/ssr
Install Claude Agent SDK (REQUIRED - not the standard SDK):
npm install @anthropic-ai/claude-agent-sdk
⚠️ Do NOT install
@anthropic-ai/sdk- that's the standard SDK without automatic tool handling. Always use the Agent SDK above.
Install Exa for web search (optional):
npm install exa-js
Install Vercel Sandbox (for production agent deployment):
npm install @vercel/sandbox
Additional dependencies:
npm install zod react-hook-form @hookform/resolvers
Guide students to create .env.local with:
# Supabase
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Anthropic
ANTHROPIC_API_KEY=your-api-key
# Exa (for web search - get key at https://exa.ai/)
EXA_API_KEY=your-exa-api-key
# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
For Vercel production deployment with agent sandbox support, add these to your Vercel project settings:
# Vercel Sandbox (required for production agent execution)
VERCEL_TOKEN=your-vercel-api-token
VERCEL_PROJECT_ID=your-project-id
VERCEL_TEAM_ID=your-team-id # Optional, only for team projects
Getting Vercel Sandbox credentials:
VERCEL_TOKEN: Create at https://vercel.com/account/tokensVERCEL_PROJECT_ID: Found in Project Settings → GeneralVERCEL_TEAM_ID: Found in Team Settings (only needed for team projects)src/
├── app/ # Next.js App Router
│ ├── (auth)/ # Auth route group
│ │ ├── login/
│ │ └── signup/
│ ├── (dashboard)/ # Protected routes
│ │ └── dashboard/
│ ├── api/ # API routes
│ │ ├── chat/
│ │ └── webhooks/
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ ├── ui/ # shadcn components
│ ├── forms/ # Form components
│ └── shared/ # Shared components
├── lib/
│ ├── supabase/
│ │ ├── client.ts # Browser client
│ │ ├── server.ts # Server client
│ │ └── middleware.ts # Auth middleware
│ ├── agent/
│ │ ├── tools.ts # Custom tools with tool() and createSdkMcpServer()
│ │ ├── config.ts # Orchestrator configuration
│ │ ├── subagents.ts # Subagent definitions (planner, executor, writer)
│ │ ├── orchestrator.ts # Pipeline runner
│ │ └── prompts.ts # System prompts for agents
│ ├── sandbox/
│ │ ├── index.ts # Environment detection
│ │ └── runner.ts # Vercel Sandbox execution
│ └── utils.ts
├── hooks/ # Custom React hooks
├── types/ # TypeScript types
└── middleware.ts # Next.js middleware
Browser Client (lib/supabase/client.ts):
createBrowserClient from @supabase/ssrNEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEYServer Client (lib/supabase/server.ts):
createServerClient from @supabase/ssrcookies() from next/headersMiddleware (middleware.ts):
Sign Up Flow:
auth.signUp() with email/passwordprofiles table via triggerSign In Flow:
auth.signInWithPassword() for email/passwordauth.signInWithOAuth()Protected Routes:
(dashboard) for protected areasSign Out:
auth.signOut() from clientBest Practices:
auth.uid() to scope queries to current userprofiles table linked to auth.usersCommon Schema Patterns:
created_at and updated_at timestampsPattern for Route Handlers (app/api/*/route.ts):
GET, POST, PUT, DELETENextRequest and return NextResponseAuthentication in API Routes:
supabase.auth.getSession()When to Use:
Pattern:
"use server" directiverevalidatePath()Adding Components:
npx shadcn@latest add button card form input
Form Pattern:
react-hook-form with zodResolverStyling Approach:
cn() utility for conditional classesClient-Side:
Server-Side:
Patterns:
The Claude Agent SDK (@anthropic-ai/claude-agent-sdk) is different from the standard Anthropic SDK. It provides automatic tool execution, session management, and built-in tools - you don't need to implement tool loops manually.
| Feature | Agent SDK | Standard Anthropic SDK |
|---|---|---|
| Tool Execution | Automatic (built-in tools) | Manual (you implement) |
| Tool Loop | Handled by SDK | You must implement |
| Built-in Tools | Yes (Read, Write, Edit, Bash, Glob, etc.) | No |
| Session Management | First-class feature | Must manage manually |
| MCP Support | Native | Limited |
query() - For single-session/one-off tasks:
import { query } from "@anthropic-ai/claude-agent-sdk";
// Simple one-off task - Claude handles tool execution automatically
for await (const message of query({
prompt: "What files are in this directory?",
options: {
allowedTools: ["Bash", "Glob"],
permissionMode: "bypassPermissions"
}
})) {
console.log(message);
}
Use the tool() function with createSdkMcpServer() to define custom tools:
Example: Weather Tool (lib/ai/tools.ts):
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
// Define tool with Zod schema
const getWeatherTool = tool(
"get_weather",
"Get current temperature for a location using coordinates",
{
latitude: z.number().describe("Latitude coordinate"),
longitude: z.number().describe("Longitude coordinate")
},
async (args) => {
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${args.latitude}&longitude=${args.longitude}¤t=temperature_2m`
);
const data = await response.json();
return {
content: [{
type: "text",
text: `Temperature: ${data.current.temperature_2m}°C`
}]
};
}
);
// Create MCP server with custom tools
export const customToolsServer = createSdkMcpServer({
name: "my-custom-tools",
version: "1.0.0",
tools: [getWeatherTool]
});
Using Custom Tools in a Query:
import { query } from "@anthropic-ai/claude-agent-sdk";
import { customToolsServer } from "@/lib/ai/tools";
for await (const message of query({
prompt: "What's the weather in San Francisco?",
options: {
mcpServers: {
"my-custom-tools": customToolsServer
},
allowedTools: ["mcp__my-custom-tools__get_weather"]
}
})) {
// Process messages
}
Exa provides neural and keyword search for finding web content, research papers, and articles. It's ideal for AI applications that need to retrieve up-to-date information.
Setup (lib/agent/tools.ts):
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import Exa from "exa-js";
// Initialize Exa client
const getExaClient = () => {
const apiKey = process.env.EXA_API_KEY;
if (!apiKey) {
throw new Error("EXA_API_KEY environment variable is not set");
}
return new Exa(apiKey);
};
// Create Exa search tools
export const exaSearchTools = createSdkMcpServer({
name: "exa-search",
version: "1.0.0",
tools: [
// Neural/Keyword Search
tool(
"search",
"Search the web using neural or keyword search. Neural search uses semantic understanding, keyword search matches exact terms.",
{
query: z.string().describe("Search query. Natural language for neural, operators (AND/OR/quotes) for keyword"),
type: z.enum(["neural", "keyword"]).default("neural").describe("Search type"),
num_results: z.number().min(1).max(20).default(5).describe("Number of results"),
include_domains: z.array(z.string()).optional().describe("Only include these domains"),
exclude_domains: z.array(z.string()).optional().describe("Exclude these domains"),
start_published_date: z.string().optional().describe("Filter: published after (YYYY-MM-DD)"),
end_published_date: z.string().optional().describe("Filter: published before (YYYY-MM-DD)"),
use_autoprompt: z.boolean().default(true).describe("Let Exa optimize the query"),
include_text: z.boolean().default(false).describe("Include text snippets")
},
async (args) => {
const exa = getExaClient();
const options: any = {
type: args.type,
numResults: args.num_results,
useAutoprompt: args.use_autoprompt
};
if (args.include_domains?.length) options.includeDomains = args.include_domains;
if (args.exclude_domains?.length) options.excludeDomains = args.exclude_domains;
if (args.start_published_date) options.startPublishedDate = args.start_published_date;
if (args.end_published_date) options.endPublishedDate = args.end_published_date;
if (args.include_text) options.contents = { text: { maxCharacters: 1000 } };
const results = await exa.searchAndContents(args.query, options);
return {
content: [{
type: "text",
text: JSON.stringify({
total: results.results.length,
results: results.results.map((r: any) => ({
title: r.title,
url: r.url,
author: r.author || "Unknown",
published_date: r.publishedDate || "Unknown",
text: r.text || null
}))
}, null, 2)
}]
};
}
),
// Get full content from URLs
tool(
"get_contents",
"Get full content from specific URLs or search result IDs",
{
ids: z.array(z.string()).min(1).max(10).describe("URLs or result IDs to fetch"),
text_length_words: z.number().min(100).max(2000).default(500).describe("Words to retrieve per document")
},
async (args) => {
const exa = getExaClient();
const contents = await exa.getContents(args.ids, {
text: { maxCharacters: args.text_length_words * 5 }
});
return {
content: [{
type: "text",
text: JSON.stringify({
documents: contents.results.map((doc: any) => ({
url: doc.url,
title: doc.title,
author: doc.author || "Unknown",
text: doc.text
}))
}, null, 2)
}]
};
}
),
// Find similar content
tool(
"find_similar",
"Find content similar to a given URL",
{
url: z.string().url().describe("URL to find similar content for"),
num_results: z.number().min(1).max(20).default(5).describe("Number of results"),
exclude_source_domain: z.boolean().default(false).describe("Exclude results from same domain")
},
async (args) => {
const exa = getExaClient();
const results = await exa.findSimilar(args.url, {
numResults: args.num_results,
excludeSourceDomain: args.exclude_source_domain
});
return {
content: [{
type: "text",
text: JSON.stringify({
source_url: args.url,
similar: results.results.map((r: any) => ({
title: r.title,
url: r.url,
author: r.author || "Unknown"
}))
}, null, 2)
}]
};
}
)
]
});
Using Exa in a Query:
import { query } from "@anthropic-ai/claude-agent-sdk";
import { exaSearchTools } from "@/lib/agent/tools";
for await (const message of query({
prompt: "Find recent papers on transformer architectures",
options: {
mcpServers: {
"exa-search": exaSearchTools
},
allowedTools: [
"mcp__exa-search__search",
"mcp__exa-search__get_contents",
"mcp__exa-search__find_similar"
]
}
})) {
console.log(message);
}
Exa Search Best Practices:
| Use Case | Search Type | Tips |
|---|---|---|
| Research papers | neural | Use autoprompt: true, filter with include_domains: ["arxiv.org"] |
| Exact term matching | keyword | Use operators: "exact phrase", term1 AND term2 |
| Recent content | neural | Set start_published_date to filter by date |
| Deep dive | get_contents | Fetch full text after initial search |
| Related work | find_similar | Expand research from a key paper |
Example: Research Agent with Exa:
// lib/agent/config.ts
import { exaSearchTools } from "./tools";
export const researchAgentConfig = {
model: "claude-haiku-4-5-20251001", // Haiku is fast and cost-effective
systemPrompt: `You are a research assistant with access to Exa search.
Use neural search for semantic queries and get_contents to read full articles.
Always cite sources with URLs.`,
mcpServers: {
"exa-search": exaSearchTools
},
allowedTools: [
"mcp__exa-search__search",
"mcp__exa-search__get_contents",
"mcp__exa-search__find_similar",
"Read",
"Write"
],
permissionMode: "bypassPermissions"
};
Control how the SDK handles tool execution:
import { query } from "@anthropic-ai/claude-agent-sdk";
// 1. acceptEdits - Auto-approve file changes (trusted dev workflows)
for await (const message of query({
prompt: "Fix the bug in auth.ts",
options: {
allowedTools: ["Read", "Edit", "Write"],
permissionMode: "acceptEdits"
}
})) { /* ... */ }
// 2. bypassPermissions - No prompts (CI/CD, automation)
for await (const message of query({
prompt: "Run the test suite",
options: {
allowedTools: ["Read", "Edit", "Bash"],
permissionMode: "bypassPermissions"
}
})) { /* ... */ }
// 3. default - Custom approval handler
for await (const message of query({
prompt: "Modify the database schema",
options: {
permissionMode: "default",
canUseTool: async (toolName, inputData) => {
// Block dangerous commands
if (toolName === "Bash" && inputData.command?.includes("rm -rf")) {
return false;
}
return true;
}
}
})) { /* ... */ }
Sessions allow Claude to remember context across multiple queries:
import { query } from "@anthropic-ai/claude-agent-sdk";
let sessionId: string | undefined;
// First query - capture session ID
for await (const message of query({
prompt: "Read the authentication module",
options: {
allowedTools: ["Read", "Glob"],
model: "claude-sonnet-4-5"
}
})) {
if (message.type === 'system' && message.subtype === 'init') {
sessionId = message.session_id;
}
console.log(message);
}
// Resume session - Claude remembers context from previous query
for await (const message of query({
prompt: "Now find all places that call it",
options: {
resume: sessionId,
model: "claude-sonnet-4-5"
}
})) {
console.log(message);
}
// Fork session - Creates a new branch to explore different approach
for await (const message of query({
prompt: "What if we used a different auth strategy?",
options: {
resume: sessionId,
forkSession: true // Creates new session branch
}
})) {
console.log(message);
}
Route Handler (app/api/agent/route.ts):
import { query } from "@anthropic-ai/claude-agent-sdk";
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const { prompt, sessionId } = await request.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
for await (const message of query({
prompt,
options: {
allowedTools: ["Read", "Glob", "Grep"],
permissionMode: "bypassPermissions",
resume: sessionId,
model: "claude-sonnet-4-5"
}
})) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(message)}\n\n`)
);
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
} catch (error) {
controller.error(error);
}
}
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
}
});
}
Client-Side Hook (hooks/useAgent.ts):
import { useState, useCallback } from "react";
interface AgentMessage {
type: string;
content?: string;
session_id?: string;
}
export function useAgent() {
const [messages, setMessages] = useState<AgentMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>();
const sendMessage = useCallback(async (prompt: string) => {
setIsLoading(true);
const response = await fetch("/api/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, sessionId })
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split("\n").filter(line => line.startsWith("data: "));
for (const line of lines) {
const data = line.slice(6);
if (data === "[DONE]") continue;
const message = JSON.parse(data);
setMessages(prev => [...prev, message]);
// Capture session ID for future queries
if (message.type === "system" && message.session_id) {
setSessionId(message.session_id);
}
}
}
setIsLoading(false);
}, [sessionId]);
return { messages, isLoading, sessionId, sendMessage };
}
Read-only analysis:
allowedTools: ["Read", "Glob", "Grep"]
Code modification:
allowedTools: ["Read", "Edit", "Write", "Glob"]
Full automation:
allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"]
Web integration:
allowedTools: ["Read", "Edit", "WebSearch", "WebFetch"]
// Custom system prompt
for await (const message of query({
prompt: "Review this code",
options: {
systemPrompt: "You are a senior TypeScript developer. Always suggest improvements."
}
})) { /* ... */ }
// Use Claude Code's preset system prompt with additions
for await (const message of query({
prompt: "Fix the bug",
options: {
systemPrompt: {
type: "preset",
preset: "claude_code",
append: "Always add type annotations to new functions."
}
}
})) { /* ... */ }
If you see this error, you're using the wrong SDK:
invalid_request_error: `tool_use` ids were found without `tool_result` blocks immediately after
Solution: You're using @anthropic-ai/sdk instead of @anthropic-ai/claude-agent-sdk. The Agent SDK handles tool loops automatically. See the "CRITICAL: SDK Selection" section at the top of this document.
If you must use the standard SDK (not recommended), here's the issue and fix:
When using the standard Anthropic SDK (not the Agent SDK), if Claude returns multiple tool_use blocks in a single response, you must provide ALL corresponding tool_result blocks together in the next user message. Failing to do this results in error 400:
invalid_request_error: `tool_use` ids were found without `tool_result` blocks immediately after
Incorrect Pattern (causes error):
// DON'T: Add messages inside the loop for each tool_use
for (const block of response.content) {
if (block.type === 'tool_use') {
const result = await executeTool(block.name, block.input);
// BAD: Adding assistant + tool_result inside loop
messages.push({ role: 'assistant', content: assistantContent });
messages.push({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: block.id, content: result }]
});
}
}
Correct Pattern:
// DO: Collect all tool_use blocks, execute them, then add ALL results at once
const toolUseBlocks = response.content.filter(
(block): block is Anthropic.ToolUseBlock => block.type === 'tool_use'
);
if (toolUseBlocks.length > 0) {
// Execute all tools and collect results
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of toolUseBlocks) {
const result = await executeTool(block.name, block.input);
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: result,
});
}
// Add assistant message once with ALL original content
messages.push({ role: 'assistant', content: response.content });
// Add single user message with ALL tool results
messages.push({ role: 'user', content: toolResults });
}
Why this matters: Claude may call multiple tools in parallel (e.g., 4 search queries at once). The API requires that each tool_use in the assistant message has a matching tool_result in the immediately following user message.
Note: The Claude Agent SDK handles this automatically. This issue only occurs when implementing your own tool loop with the standard
@anthropic-ai/sdk.
When processing messages from the Agent SDK's query() function, tool results do NOT come in assistant type messages. This is a common mistake that causes subagents to return 0 results.
Tool results come in user type messages:
// ❌ WRONG - Looking for tool results in assistant messages
if (message.type === 'assistant') {
// Tool results are NOT here!
}
// ✅ CORRECT - Tool results come in user messages
if (message.type === 'user') {
const msg = message as any;
if (msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'tool_result') {
// Tool results are HERE!
const contentArray = Array.isArray(block.content)
? block.content
: typeof block.content === 'string'
? [{ type: 'text', text: block.content }]
: [];
for (const item of contentArray) {
if (item.type === 'text' && item.text) {
const parsed = JSON.parse(item.text);
// Process parsed results...
}
}
}
}
}
}
Message structure from Agent SDK:
{
"type": "user",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_xxx",
"content": [
{
"type": "text",
"text": "[{\"title\": \"...\", \"url\": \"...\"}]"
}
]
}
]
}
}
When creating custom tools with tool() and createSdkMcpServer(), your tools return raw arrays, not objects with a results key. Your parsing code must handle this.
Example tool returning raw array:
// In tools.ts - tool returns a raw array
const searchTool = tool(
'search',
'Search the web',
{ query: z.string() },
async (args) => {
const results = await exa.search(args.query);
// Returns raw array, NOT { results: [...] }
const formattedResults = results.results.map((r) => ({
title: r.title,
url: r.url,
snippet: r.text || '',
}));
return {
content: [{
type: 'text' as const,
text: JSON.stringify(formattedResults, null, 2), // Raw array!
}],
};
}
);
Parsing code must handle raw arrays:
// ❌ WRONG - Only checking for object with results key
if (parsed.results && Array.isArray(parsed.results)) {
// Won't work with raw arrays!
}
// ✅ CORRECT - Handle raw array format first
if (Array.isArray(parsed)) {
for (const result of parsed) {
if (result.title && result.url) {
// Process each result...
allResults.push({
title: result.title,
url: result.url,
snippet: result.snippet || result.content?.slice(0, 200),
content: result.content,
});
}
}
}
// Also handle object format (for compatibility)
if (parsed.results && Array.isArray(parsed.results)) {
for (const result of parsed.results) {
// Process...
}
}
Complete example for parsing tool results in a subagent:
// lib/agent/subagents.ts
export async function runWebSearchAgent(
plan: SearchPlan,
onSource: (source: Source) => void
): Promise<SearchResult[]> {
const allResults: SearchResult[] = [];
for await (const message of query({
prompt: `Execute searches: ${JSON.stringify(plan.queries)}`,
options: {
model: 'claude-haiku-4-5-20251001',
mcpServers: { 'exa-search': exaSearchTools },
allowedTools: ['mcp__exa-search__search', 'mcp__exa-search__get_contents'],
permissionMode: 'bypassPermissions',
},
})) {
// ✅ Check for user messages containing tool_result blocks
if (message.type === 'user') {
const msg = message as any;
if (msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'tool_result') {
const contentArray = Array.isArray(block.content)
? block.content
: typeof block.content === 'string'
? [{ type: 'text', text: block.content }]
: [];
for (const item of contentArray) {
if (item.type === 'text' && item.text) {
try {
const parsed = JSON.parse(item.text);
// ✅ Handle raw array format (from custom tools)
if (Array.isArray(parsed)) {
for (const result of parsed) {
if (result.title && result.url) {
onSource({ title: result.title, url: result.url, snippet: result.snippet });
allResults.push(result);
}
}
}
} catch (e) {
// JSON parse error - skip
}
}
}
}
}
}
}
}
return allResults;
}
import {
query,
CLINotFoundError,
ProcessError,
CLIJSONDecodeError
} from "@anthropic-ai/claude-agent-sdk";
try {
for await (const message of query({ prompt: "Hello" })) {
console.log(message);
}
} catch (error) {
if (error instanceof CLINotFoundError) {
console.error("Please install Claude Code: npm install -g @anthropic-ai/claude-code");
} else if (error instanceof ProcessError) {
console.error(`Process failed with exit code: ${error.exitCode}`);
} else if (error instanceof CLIJSONDecodeError) {
console.error(`Failed to parse response: ${error}`);
}
}
// claude-haiku-4-5-20251001: Fast and cost-effective (RECOMMENDED for most agent tasks)
options: { model: "claude-haiku-4-5-20251001" }
// claude-sonnet-4-5: Balanced performance and cost (for complex orchestration)
options: { model: "claude-sonnet-4-5" }
// claude-opus-4-5: Complex reasoning and analysis (rarely needed)
options: { model: "claude-opus-4-5" }
Recommended model strategy for multi-agent systems:
| Agent Type | Recommended Model | Why |
|---|---|---|
| Planner Subagent | claude-haiku-4-5-20251001 | Planning is structured, haiku handles well |
| Web Search Subagent | claude-haiku-4-5-20251001 | Tool execution doesn't need complex reasoning |
| Report Writer Subagent | claude-haiku-4-5-20251001 | Fast generation with good quality |
| Main Orchestrator | claude-haiku-4-5-20251001 | Coordination is simple, haiku is sufficient |
| Complex Analysis | claude-sonnet-4-5 | Only when deep reasoning is required |
For complex tasks like research, content generation, or multi-step workflows, use an orchestrator pattern where a main agent delegates to specialized subagents.
User Input
↓
Orchestrator Agent (coordinates the pipeline)
├→ Stage 1: Planner Subagent (creates plan/queries)
├→ Stage 2: Executor Subagent (executes searches/actions)
└→ Stage 3: Writer Subagent (generates final output)
↓
Final Result
Define specialized subagents with specific roles, tools, and models:
// lib/agent/subagents.ts
export const PLANNER_SUBAGENT = {
name: "planner",
description: "Analyzes the research topic and creates optimized search queries",
model: "claude-haiku-4-5-20251001",
tools: [], // No tools - planning only
systemPrompt: `You are a research planning specialist. Your task is to analyze
a research topic and create a structured search plan.
OUTPUT FORMAT (JSON):
{
"date_range": {
"start": "YYYY-MM-DD",
"end": "YYYY-MM-DD"
},
"search_queries": [
{
"query": "specific search query",
"type": "neural" | "keyword",
"priority": 1-4,
"rationale": "why this query is important"
}
]
}
Create 4-6 diverse queries covering different aspects of the topic.`
};
export const WEB_SEARCH_SUBAGENT = {
name: "web-search",
description: "Executes search plan and gathers sources using Exa",
model: "claude-haiku-4-5-20251001",
tools: [
"mcp__exa-research__search",
"mcp__exa-research__get_contents"
],
systemPrompt: `You are a web research specialist. Execute the provided search
plan systematically.
PROCESS:
1. Run each search query from the plan
2. Identify the 6 most relevant URLs from results
3. Fetch full content from those URLs using get_contents
4. Return all gathered information
Always complete ALL searches before moving to content fetching.`
};
export const REPORT_WRITER_SUBAGENT = {
name: "report-writer",
description: "Creates comprehensive research report from gathered sources",
model: "claude-haiku-4-5-20251001",
tools: [], // No tools - writing only
systemPrompt: `You are a research report writer. Create a comprehensive,
well-structured report from the provided research findings.
OUTPUT FORMAT (Markdown):
## Summary
Brief 2-3 paragraph overview
## Key Findings
- Finding 1 with [source](url)
- Finding 2 with [source](url)
## Detailed Analysis
In-depth discussion organized by theme
## Conclusion
Key takeaways and implications
## References
All sources cited with URLs
Always cite sources inline with markdown links.`
};
The orchestrator coordinates subagents and tracks pipeline progress:
// lib/agent/config.ts
import { exaSearchTools } from "./tools";
export const ORCHESTRATOR_CONFIG = {
model: "claude-haiku-4-5-20251001",
systemPrompt: `You are a research orchestrator managing a 3-stage pipeline.
CRITICAL RULES:
1. Execute stages in EXACT order: Planner → WebSearch → ReportWriter
2. Announce each stage with: "STAGE: [stage-name]"
3. Pass complete data between stages
4. Never skip stages or execute out of order
PIPELINE:
1. STAGE: Planner - Create search plan from topic
2. STAGE: WebSearch - Execute searches, gather sources
3. STAGE: ReportWriter - Generate final report
Start by announcing "STAGE: Planner" and delegating to the planning subagent.`,
mcpServers: {
"exa-research": exaSearchTools
},
allowedTools: [
"mcp__exa-research__search",
"mcp__exa-research__get_contents",
"Task" // For delegating to subagents
],
disallowedTools: [
"WebFetch",
"WebSearch" // Use Exa instead
],
permissionMode: "bypassPermissions"
};
// lib/agent/orchestrator.ts
import { query } from "@anthropic-ai/claude-agent-sdk";
import { ORCHESTRATOR_CONFIG } from "./config";
import {
PLANNER_SUBAGENT,
WEB_SEARCH_SUBAGENT,
REPORT_WRITER_SUBAGENT
} from "./subagents";
export async function* runResearchPipeline(topic: string) {
const subagents = [
PLANNER_SUBAGENT,
WEB_SEARCH_SUBAGENT,
REPORT_WRITER_SUBAGENT
];
for await (const message of query({
prompt: `Research this topic thoroughly: ${topic}`,
options: {
model: ORCHESTRATOR_CONFIG.model,
systemPrompt: ORCHESTRATOR_CONFIG.systemPrompt,
mcpServers: ORCHESTRATOR_CONFIG.mcpServers,
allowedTools: ORCHESTRATOR_CONFIG.allowedTools,
disallowedTools: ORCHESTRATOR_CONFIG.disallowedTools,
permissionMode: ORCHESTRATOR_CONFIG.permissionMode,
subagents
}
})) {
// Detect stage changes from orchestrator announcements
if (message.type === "assistant" && message.content) {
const text = typeof message.content === "string"
? message.content
: message.content.find((b: any) => b.type === "text")?.text || "";
const stageMatch = text.match(/STAGE:\s*(\w+)/i);
if (stageMatch) {
yield {
type: "stage_change",
stage: stageMatch[1].toLowerCase(),
timestamp: Date.now()
};
}
}
yield message;
}
}
| Practice | Description |
|---|---|
| Explicit Announcements | Orchestrator says "STAGE: X" for parsing |
| Sequential Execution | Never parallelize dependent stages |
| Complete Data Passing | Pass full context between stages |
| Subagent Isolation | Each subagent has specific tools only |
| Model Selection | Use haiku for subagents, sonnet for complex orchestration |
Real-time progress tracking requires structured Server-Sent Events (SSE) with multiple message types.
Define consistent message types for the frontend:
// types/research.ts
export type PipelineStage = "orchestrator" | "planner" | "web-search" | "report-writer";
export interface StageChangeMessage {
type: "stage_change";
stage: PipelineStage;
timestamp: number;
description?: string;
}
export interface StatusMessage {
type: "status";
content: string;
}
export interface ResultMessage {
type: "result";
result: string;
session_id?: string;
}
export interface ErrorMessage {
type: "error";
content: string;
}
export type PipelineMessage =
| StageChangeMessage
| StatusMessage
| ResultMessage
| ErrorMessage;
// app/api/research/route.ts
import { NextRequest } from "next/server";
import { runResearchPipeline } from "@/lib/agent/orchestrator";
export const maxDuration = 300; // 5 minutes for long research
export async function POST(request: NextRequest) {
const { topic } = await request.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const sendMessage = (msg: object) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(msg)}\n\n`)
);
};
try {
sendMessage({ type: "status", content: "Starting research pipeline..." });
for await (const message of runResearchPipeline(topic)) {
// Forward stage changes
if (message.type === "stage_change") {
sendMessage(message);
continue;
}
// Extract text content for final result
if (message.type === "assistant") {
const content = message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text") {
// Check if this looks like the final report
if (block.text.includes("## Summary") ||
block.text.includes("## Key Findings")) {
sendMessage({
type: "result",
result: block.text,
session_id: message.session_id
});
}
}
}
}
}
// Forward tool usage as status
if (message.type === "tool_use") {
sendMessage({
type: "status",
content: `Using tool: ${message.name}`
});
}
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
} catch (error) {
sendMessage({
type: "error",
content: error instanceof Error ? error.message : "Unknown error"
});
} finally {
controller.close();
}
}
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
}
});
}
Complex agent interactions require structured state management on the frontend.
// hooks/useResearchAgent.ts
import { useState, useCallback, useRef } from "react";
import type { PipelineStage, PipelineMessage } from "@/types/research";
interface StageProgress {
stage: PipelineStage;
status: "pending" | "active" | "completed";
startTime?: number;
endTime?: number;
}
interface ResearchSource {
title: string;
url: string;
author?: string;
snippet?: string;
}
interface ResearchState {
status: "idle" | "researching" | "completed" | "error";
currentStage: PipelineStage | null;
stages: StageProgress[];
sources: ResearchSource[];
report: string;
sessionId?: string;
error?: string;
}
const INITIAL_STAGES: StageProgress[] = [
{ stage: "planner", status: "pending" },
{ stage: "web-search", status: "pending" },
{ stage: "report-writer", status: "pending" }
];
export function useResearchAgent() {
const [state, setState] = useState<ResearchState>({
status: "idle",
currentStage: null,
stages: INITIAL_STAGES,
sources: [],
report: "",
});
const abortRef = useRef<AbortController | null>(null);
const startResearch = useCallback(async (topic: string) => {
abortRef.current = new AbortController();
setState(prev => ({
...prev,
status: "researching",
stages: INITIAL_STAGES,
sources: [],
report: "",
error: undefined
}));
try {
const response = await fetch("/api/research", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topic }),
signal: abortRef.current.signal
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split("\n").filter(l => l.startsWith("data: "));
for (const line of lines) {
const data = line.slice(6);
if (data === "[DONE]") continue;
const message: PipelineMessage = JSON.parse(data);
switch (message.type) {
case "stage_change":
setState(prev => ({
...prev,
currentStage: message.stage,
stages: prev.stages.map(s => {
if (s.stage === message.stage) {
return { ...s, status: "active", startTime: Date.now() };
}
if (s.status === "active") {
return { ...s, status: "completed", endTime: Date.now() };
}
return s;
})
}));
break;
case "result":
setState(prev => ({
...prev,
status: "completed",
report: message.result,
sessionId: message.session_id,
stages: prev.stages.map(s =>
s.status === "active"
? { ...s, status: "completed", endTime: Date.now() }
: s
)
}));
break;
case "error":
setState(prev => ({
...prev,
status: "error",
error: message.content
}));
break;
}
}
}
} catch (error) {
if (error instanceof Error && error.name !== "AbortError") {
setState(prev => ({
...prev,
status: "error",
error: error.message
}));
}
}
}, []);
const cancelResearch = useCallback(() => {
abortRef.current?.abort();
setState(prev => ({ ...prev, status: "idle" }));
}, []);
const reset = useCallback(() => {
setState({
status: "idle",
currentStage: null,
stages: INITIAL_STAGES,
sources: [],
report: ""
});
}, []);
return {
...state,
startResearch,
cancelResearch,
reset
};
}
Parse tool results to extract discovered sources:
// Inside useResearchAgent, add source extraction:
const extractSources = (message: any): ResearchSource[] => {
if (message.type !== "tool_result") return [];
try {
const content = JSON.parse(message.content);
if (content.results) {
return content.results.map((r: any) => ({
title: r.title,
url: r.url,
author: r.author,
snippet: r.text?.slice(0, 200)
}));
}
} catch {
return [];
}
return [];
};
// Deduplicate sources by URL
const addSources = (newSources: ResearchSource[]) => {
setState(prev => {
const existingUrls = new Set(prev.sources.map(s => s.url));
const uniqueNew = newSources.filter(s => !existingUrls.has(s.url));
return {
...prev,
sources: [...prev.sources, ...uniqueNew]
};
});
};
Why Sandbox? The Claude Agent SDK spawns subprocesses for MCP servers, but Vercel serverless functions cannot spawn subprocesses. Vercel Sandbox provides isolated containers that can.
| Environment | Approach |
|---|---|
| Local Development | Direct SDK execution (no sandbox needed) |
| Vercel Production | Use Vercel Sandbox for agent execution |
// lib/sandbox/index.ts
export function isVercelEnvironment(): boolean {
return Boolean(process.env.VERCEL || process.env.VERCEL_ENV);
}
// lib/sandbox/runner.ts
import { Sandbox } from "@vercel/sandbox";
export async function runAgentInSandbox(
topic: string,
onMessage: (msg: object) => void
) {
const sandbox = await Sandbox.create({
runtime: "node22",
timeout: 300_000, // 5 minutes
// Auth - set these in Vercel dashboard
token: process.env.VERCEL_TOKEN,
projectId: process.env.VERCEL_PROJECT_ID,
teamId: process.env.VERCEL_TEAM_ID // Optional, for team projects
});
try {
// 1. Write package.json
await sandbox.writeFile("/vercel/sandbox/package.json", JSON.stringify({
name: "research-agent",
type: "module",
dependencies: {
"@anthropic-ai/claude-agent-sdk": "^0.1.72",
"exa-js": "^2.0.11",
"zod": "^3.25.0"
}
}));
// 2. Write research script (include your agent code here)
const script = generateResearchScript(topic);
await sandbox.writeFile("/vercel/sandbox/index.js", script);
// 3. Install dependencies
await sandbox.runCommand({
cmd: "npm",
args: ["install"],
cwd: "/vercel/sandbox"
});
// 4. Execute agent
const result = await sandbox.runCommand({
cmd: "node",
args: ["index.js"],
cwd: "/vercel/sandbox",
env: {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!,
EXA_API_KEY: process.env.EXA_API_KEY!
}
});
// 5. Parse output (use markers for structured messages)
const lines = result.stdout.split("\n");
for (const line of lines) {
if (line.startsWith("__MSG__")) {
const json = line.replace("__MSG__", "");
onMessage(JSON.parse(json));
}
}
} finally {
await sandbox.stop();
}
}
function generateResearchScript(topic: string): string {
// Escape the topic for safe embedding
const escapedTopic = topic.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
return `
// Dynamic research script for sandbox execution
import { query } from "@anthropic-ai/claude-agent-sdk";
const topic = \`${escapedTopic}\`;
// Output helper - prefix messages for parsing
const emit = (msg) => console.log("__MSG__" + JSON.stringify(msg));
async function main() {
emit({ type: "status", content: "Starting research..." });
for await (const message of query({
prompt: \`Research: \${topic}\`,
options: {
// Your agent configuration here
permissionMode: "bypassPermissions"
}
})) {
// Forward relevant messages
if (message.type === "assistant") {
emit({ type: "progress", content: message });
}
}
emit({ type: "done" });
}
main().catch(err => {
emit({ type: "error", content: err.message });
process.exit(1);
});
`;
}
// app/api/research/route.ts
import { isVercelEnvironment } from "@/lib/sandbox";
import { runAgentInSandbox } from "@/lib/sandbox/runner";
import { runResearchPipeline } from "@/lib/agent/orchestrator";
export async function POST(request: NextRequest) {
const { topic } = await request.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const sendMessage = (msg: object) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(msg)}\n\n`));
};
try {
if (isVercelEnvironment()) {
// Production: Use Vercel Sandbox
await runAgentInSandbox(topic, sendMessage);
} else {
// Development: Direct SDK execution
for await (const msg of runResearchPipeline(topic)) {
sendMessage(msg);
}
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
} catch (error) {
sendMessage({ type: "error", content: String(error) });
} finally {
controller.close();
}
}
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache"
}
});
}
Add these to your Vercel project settings:
# Required for Vercel Sandbox
VERCEL_TOKEN=your-vercel-api-token
VERCEL_PROJECT_ID=your-project-id
VERCEL_TEAM_ID=your-team-id # Optional, only for team projects
# Agent API Keys (passed to sandbox)
ANTHROPIC_API_KEY=your-api-key
EXA_API_KEY=your-exa-api-key
Students can build in stages:
The environment detection automatically routes to the correct execution method.
Rate Limiting:
Security:
canUseTool handler to block dangerous operationsallowedTools to minimum necessaryCost Management:
Strict Mode:
strict: true in tsconfig.jsonany type; use unknown when type is uncertainPath Aliases:
@/* alias for clean importsValidation:
Security:
NEXT_PUBLIC_ prefix only for public varsInstall Vercel CLI:
npm install -g vercel
Login to Vercel:
vercel login
First Deployment (Project Setup):
vercel
Production Deployment:
vercel --prod
Preview Deployments:
vercel
Environment Variables via CLI:
# Add environment variable
vercel env add ANTHROPIC_API_KEY production
# Pull env vars to local .env file
vercel env pull .env.local
# List all env vars
vercel env ls
Useful CLI Commands:
# View deployment logs
vercel logs <deployment-url>
# List recent deployments
vercel ls
# Inspect a deployment
vercel inspect <deployment-url>
# Promote preview to production
vercel promote <deployment-url>
# Rollback to previous deployment
vercel rollback
Deployment Checklist:
vercel env pull to sync environment variablesvercel dev (uses Vercel's development server)vercel and testvercel --prodDatabase:
Performance:
Authentication:
Data Access:
API Security:
AI-Specific:
npx claudepluginhub dair-ai/dair-cc-plugins --plugin ai-agent-builderGuides programmatic control of Claude Code sessions via Claude Agent SDK in TypeScript/JavaScript or Python. Supports custom agents, tools, streaming, and event handling for building AI agents.
Guides Claude Agent SDK development in TypeScript/Python: auth, sessions, custom tools, permissions, prompts, tracking via docs-management delegation.
Builds autonomous AI agents with Claude Agent SDK: computer use, tool calling, MCP integration, and production best practices for Anthropic models.