The AI SDK is the right default for tool calling. Reach for the raw Anthropic SDK when you want full control over the message loop, native tool_use blocks, or Anthropic-specific features. This pattern uses Wikipedia as a free, no-auth research source.
npm install hono @anthropic-ai/sdk zodDefine Tools and Schemas
Anthropic tools use JSON Schema for input_schema. Validate the model's tool input with Zod before calling the implementation.
import Anthropic from '@anthropic-ai/sdk';
import { z } from 'zod';
const SearchInput = z.object({ query: z.string() });
const GetArticleInput = z.object({ title: z.string() });
const FinishResearchInput = z.object({
summary: z.string(),
sourcesUsed: z.number(),
});
const WikipediaSearchResponse = z.object({
query: z
.object({
search: z.array(z.object({ title: z.string(), snippet: z.string() })).optional(),
})
.optional(),
});
const WikipediaExtractResponse = z.object({
query: z
.object({
pages: z.record(z.string(), z.object({ extract: z.string().optional() })).optional(),
})
.optional(),
});
const tools: Anthropic.Tool[] = [
{
name: 'search_wikipedia',
description: 'Search Wikipedia for articles matching a query. Returns titles and snippets.',
input_schema: {
type: 'object',
properties: { query: { type: 'string', description: 'The search query' } },
required: ['query'],
},
},
{
name: 'get_article',
description: 'Get the introductory text of a Wikipedia article by title.',
input_schema: {
type: 'object',
properties: { title: { type: 'string', description: 'Exact article title' } },
required: ['title'],
},
},
{
name: 'finish_research',
description: 'Call when you have enough information to write a 2-3 paragraph summary.',
input_schema: {
type: 'object',
properties: {
summary: { type: 'string' },
sourcesUsed: { type: 'number' },
},
required: ['summary', 'sourcesUsed'],
},
},
];finish_research doubles as the structured output mechanism. The loop ends when the model calls it, and the call's input becomes the final result.
Implement the Tools
Wikipedia exposes search and extract endpoints over plain fetch.
async function searchWikipedia(query: string): Promise<string> {
const url = `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&format=json&origin=*&srlimit=3`;
const res = await fetch(url);
if (!res.ok) return `Search failed: HTTP ${res.status}`;
const parsed = WikipediaSearchResponse.safeParse(await res.json());
if (!parsed.success) return 'No results found.';
const results = parsed.data.query?.search ?? [];
if (results.length === 0) return 'No results found.';
return JSON.stringify(
results.map((r) => ({ title: r.title, snippet: r.snippet.replace(/<[^>]*>/g, '') }))
);
}
async function getArticle(title: string): Promise<string> {
const url = `https://en.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=extracts&exintro=true&explaintext=true&format=json&origin=*`;
const res = await fetch(url);
if (!res.ok) return `Failed to fetch article: HTTP ${res.status}`;
const parsed = WikipediaExtractResponse.safeParse(await res.json());
if (!parsed.success) return 'No content found.';
const pages = parsed.data.query?.pages;
if (!pages) return 'No content found.';
const [page] = Object.values(pages);
return page?.extract ?? 'No content found.';
}
async function executeTool(name: string, input: unknown): Promise<string> {
switch (name) {
case 'search_wikipedia': {
const { query } = SearchInput.parse(input);
return searchWikipedia(query);
}
case 'get_article': {
const { title } = GetArticleInput.parse(input);
return getArticle(title);
}
default:
return `Unknown tool: ${name}`;
}
}Run the Loop
The loop sends messages, checks stop_reason, executes any tool_use blocks, and feeds the results back. It exits when the model calls finish_research or hits the step cap.
const SYSTEM_PROMPT = `You are a research assistant that investigates topics using Wikipedia.
Follow this loop:
1. Plan: decide what to search for next
2. Search: call search_wikipedia
3. Read: call get_article on the most relevant result
4. Finish: when you have enough (usually 2-4 sources), call finish_research`;
const MAX_STEPS = 8;
const client = new Anthropic();
const model = process.env.ANTHROPIC_MODEL;
if (!model) {
throw new Error('Set ANTHROPIC_MODEL to the model this workflow should use.');
}
interface ResearchResult {
readonly summary: string;
readonly sourcesUsed: number;
}
export async function research(topic: string): Promise<ResearchResult> {
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: `Research this topic thoroughly: ${topic}` },
];
for (let step = 0; step < MAX_STEPS; step++) {
const response = await client.messages.create({
model,
max_tokens: 4096,
system: SYSTEM_PROMPT,
tools,
messages,
});
if (response.stop_reason !== 'tool_use') break;
messages.push({ role: 'assistant', content: response.content });
const toolResults: Anthropic.ToolResultBlockParam[] = [];
let finalResult: ResearchResult | undefined;
for (const block of response.content) {
if (block.type !== 'tool_use') continue;
if (block.name === 'finish_research') {
finalResult = FinishResearchInput.parse(block.input);
continue;
}
const result = await executeTool(block.name, block.input);
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: result });
}
if (finalResult) {
return finalResult;
}
messages.push({ role: 'user', content: toolResults });
}
return { summary: 'Research could not be completed. Try a more specific topic.', sourcesUsed: 0 };
}A few details that matter:
- assistant turns carry
tool_useblocks; the next user turn carries matchingtool_resultblocks. Anthropic enforces that pairing. finish_researchhas a JSON Schema, soblock.inputis the validated final summary. No secondgenerateObjectcall needed.- the loop breaks when the model returns
stop_reason !== 'tool_use', which means it stopped onend_turnor hitmax_tokens.
Wire the Route
The route validates the input topic and calls the agent function. The function is plain async code, so it works the same way from a queue consumer or a script.
import { Hono } from 'hono';
import { research } from './lib/research-agent';
import { z } from 'zod';
const requestSchema = z.object({ topic: z.string() });
const app = new Hono();
app.post('/api/research', async (c) => {
const body: unknown = await c.req.json();
const { topic } = requestSchema.parse(body);
const result = await research(topic);
return c.json(result);
});
export default app;When to reach for the Anthropic SDK
| Pick this when | Otherwise prefer |
|---|---|
You want native tool_use and tool_result block visibility | AI SDK |
| You need Anthropic-specific features like extended thinking, caching, or batch | AI SDK |
| You want full control over the message conversation shape | AI SDK with Output.object |
Run locally with agentuity dev to use AI Gateway env wiring for the Anthropic SDK. For deployed apps, set provider keys directly unless you have verified the project receives the gateway env your SDK path needs.
Next Steps
- Agents: the plain-function pattern this page uses
- Tool Calling: the AI SDK path with provider-agnostic tool calls
- LLM as a Judge: score the research output before returning it