Web Search Agent
This example builds a small search-capable agent:
- The user asks a question.
- The LLM decides whether to call
web_search. - The tool calls Exa's hosted MCP server.
- Search results are returned to the LLM.
- The LLM writes the final answer.
The example uses Exa's hosted MCP endpoint at https://mcp.exa.ai/mcp, so it does not require an Exa API key.
ts
import {
anthropic,
createAgent,
memoryStorage,
tool,
} from "duclaw-cli/sdk";
type ExaSearchResult = {
title?: string;
url?: string;
text?: string;
publishedDate?: string;
};
type JsonRpcResponse<T> = {
result?: T;
error?: {
message?: string;
};
};
const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
// Helper: call Exa's hosted MCP server. This is adapter code, not SDK magic.
async function callExaTool<T>(name: string, args: Record<string, unknown>): Promise<T> {
const response = await fetch(EXA_MCP_URL, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json, text/event-stream",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: crypto.randomUUID(),
method: "tools/call",
params: {
name,
arguments: args,
},
}),
});
if (!response.ok) {
throw new Error(`Exa MCP request failed: ${response.status}`);
}
const body = await response.text();
const payload = parseMcpResponse<JsonRpcResponse<T>>(body);
if (payload.error) {
throw new Error(payload.error.message ?? "Exa MCP tool call failed");
}
if (!payload.result) {
throw new Error("Exa MCP returned an empty result");
}
return payload.result;
}
// Helper: Exa MCP may respond as either JSON or a text/event-stream message.
function parseMcpResponse<T>(body: string): T {
const eventData = body
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice("data: ".length);
return JSON.parse(eventData ?? body) as T;
}
// Key SDK concept: tools are typed capabilities that the LLM can choose during the agent loop.
const webSearch = tool({
name: "web_search",
description: "Search the web for current information and return concise results.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "The web search query.",
},
numResults: {
type: "number",
description: "Number of search results to return.",
},
},
required: ["query"],
additionalProperties: false,
},
async run({ query, numResults = 5 }) {
// Adapter boundary: the tool can call any external service and return structured data.
const result = await callExaTool<{ content?: Array<{ text?: string }> }>(
"web_search_exa",
{
query,
numResults,
type: "fast",
},
);
const text = result.content?.map((item) => item.text).filter(Boolean).join("\n\n") ?? "";
return normalizeSearchResults(text);
},
});
// Helper: normalize provider-specific output before returning it to the LLM.
function normalizeSearchResults(raw: string): ExaSearchResult[] {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed.results) ? parsed.results : [];
} catch {
return [{ title: "Search results", text: raw }];
}
}
// Key SDK concept: the host app injects the model, storage, tools, and system prompt.
const agent = createAgent({
model: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }),
storage: memoryStorage(),
tools: [webSearch],
system: [
"You answer developer questions with current web context when needed.",
"Use web_search for recent facts, package versions, docs, or news.",
"Cite source URLs from the search results in the final answer.",
].join("\n"),
});
// Application code: pass user input into the agent loop and print the final LLM answer.
async function main() {
const result = await agent.run({
input: "What changed in the latest VitePress release?",
context: {
userId: "developer",
requestId: crypto.randomUUID(),
},
});
console.log(result.output);
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});Tool Loop
The SDK owns the agent loop. Your application provides the model, storage, and tools:
txt
user input
-> createAgent(...).run()
-> LLM chooses web_search
-> Exa MCP returns search results
-> LLM receives tool result
-> final answerThe important boundary is that web_search is just a normal SDK tool. You can replace Exa with another search provider without changing the agent API.