Skip to content

Web Search Agent

This example builds a small search-capable agent:

  1. The user asks a question.
  2. The LLM decides whether to call web_search.
  3. The tool calls Exa's hosted MCP server.
  4. Search results are returned to the LLM.
  5. 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 answer

The 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.

Released as part of the duclaw-cli package.