API documentation

Recipe: AI agent with tool calls

A direct-Anthropic-SDK agent that quotes, submits, polls, and verifies — driven by Claude making tool calls. Useful when you want full control over the loop, don't want a Vercel AI SDK dependency, or are bridging from a different runtime.

When to use this vs. the AI SDK

Pick the right tool for the job. There are real tradeoffs.

  • Use this recipe when:you want a minimal dep footprint (one SDK, one HTTP client), you're building a CLI / worker rather than a web app, or you already depend on @anthropic-ai/sdkand don't want a second tool-calling abstraction.
  • Use @verdacert/ai-sdk-tools instead when:you want typed Zod schemas with inferred input types, you're deploying on Vercel and want AI Gateway routing, or you may want to swap providers (OpenAI, Google) later.

Install

One SDK. The Verdacert client is just fetch.

pnpm add @anthropic-ai/sdk
# or: npm i @anthropic-ai/sdk
# .env — server-side only
VERDACERT_API_KEY=vc_sandbox_…
ANTHROPIC_API_KEY=sk-ant-…

Tool definitions

JSON-schema declarations the model uses to decide what to call.

// tools.ts
import type Anthropic from "@anthropic-ai/sdk";

export const VERDACERT_TOOLS: Anthropic.Tool[] = [
  {
    name: "verdacert_get_capabilities",
    description:
      "Returns supported languages, document types, speed tiers, and add-ons. Call once before quoting to ground on live enums.",
    input_schema: { type: "object", properties: {}, required: [] },
  },
  {
    name: "verdacert_quote",
    description:
      "Get a binding price + ETA quote. Returns quoteId valid for 24h. Free — does not create an order.",
    input_schema: {
      type: "object",
      properties: {
        sourceLanguage: { type: "string", description: "ISO 639-1/2 code, e.g. 'fa'." },
        useCase:        { type: "string", enum: ["uscis","court","university","medical","employer","embassy","apostille_outbound","other"] },
        pageCount:      { type: "integer", minimum: 1, maximum: 500 },
        speedTier:      { type: "string", enum: ["standard","express","rush"] },
      },
      required: ["sourceLanguage","useCase","pageCount","speedTier"],
    },
  },
  {
    name: "verdacert_submit",
    description:
      "Create a translation job. Idempotent on (apiKey, idempotencyKey). Returns jobId.",
    input_schema: {
      type: "object",
      properties: {
        quoteId:        { type: "string" },
        idempotencyKey: { type: "string", minLength: 8, maxLength: 128 },
        echoedInput:    { type: "object" },
        documentUrl:    { type: "string" },
      },
      required: ["quoteId","idempotencyKey","echoedInput","documentUrl"],
    },
  },
  {
    name: "verdacert_get_status",
    description: "Poll a job's current state. Returns status + progressPercent.",
    input_schema: {
      type: "object",
      properties: { jobId: { type: "string" } },
      required: ["jobId"],
    },
  },
  {
    name: "verdacert_get_result",
    description:
      "Fetch certified PDF URL + JWS receipt. Only valid once status is 'ready' or 'delivered'.",
    input_schema: {
      type: "object",
      properties: { jobId: { type: "string" } },
      required: ["jobId"],
    },
  },
];

Tool executors

Server-side implementations that the loop dispatches to.

// executors.ts
const auth = {
  Authorization: `Bearer ${process.env.VERDACERT_API_KEY!}`,
  "Content-Type": "application/json",
};
const BASE = "https://verdacert.com/api/v1";

export async function execute(name: string, args: any): Promise<unknown> {
  switch (name) {
    case "verdacert_get_capabilities":
      return (await fetch(`${BASE}/capabilities`, { headers: auth })).json();

    case "verdacert_quote":
      return (await fetch(`${BASE}/quote`, {
        method: "POST", headers: auth, body: JSON.stringify(args),
      })).json();

    case "verdacert_submit": {
      const { documentUrl, ...rest } = args;
      return (await fetch(`${BASE}/submit`, {
        method: "POST",
        headers: auth,
        body: JSON.stringify({
          ...rest,
          documents: [{ url: documentUrl }],
        }),
      })).json();
    }

    case "verdacert_get_status":
      return (await fetch(`${BASE}/status/${encodeURIComponent(args.jobId)}`, { headers: auth })).json();

    case "verdacert_get_result":
      return (await fetch(`${BASE}/result/${encodeURIComponent(args.jobId)}`, { headers: auth })).json();

    default:
      throw new Error(`Unknown tool: ${name}`);
  }
}

The agent loop

Standard Anthropic tool-calling pattern. Each turn either returns text or asks for tool calls; we execute and loop.

// agent.ts
import Anthropic from "@anthropic-ai/sdk";
import { VERDACERT_TOOLS } from "./tools";
import { execute } from "./executors";

const MAX_TURNS = 20;

export async function runAgent(userMessage: string): Promise<string> {
  const client = new Anthropic();

  const messages: Anthropic.MessageParam[] = [
    { role: "user", content: userMessage },
  ];

  for (let turn = 0; turn < MAX_TURNS; turn++) {
    const response = await client.messages.create({
      model:      "claude-opus-4-7",
      max_tokens: 4096,
      tools:      VERDACERT_TOOLS,
      messages,
      system:
        "You are an agent that helps users get certified document " +
        "translations through Verdacert. Always call get_capabilities " +
        "first to ground on live enums. Before submit, confirm price " +
        "with the user. Use a stable idempotencyKey of the form " +
        "'agent-' + a slug of the user request.",
    });

    // Append the assistant turn so the model sees its own tool calls.
    messages.push({ role: "assistant", content: response.content });

    // Terminal: model is done.
    if (response.stop_reason === "end_turn") {
      return response.content
        .filter((b): b is Anthropic.TextBlock => b.type === "text")
        .map((b) => b.text)
        .join("\n");
    }

    // Otherwise run any tool_use blocks and feed results back.
    const toolResults: Anthropic.ToolResultBlockParam[] = [];
    for (const block of response.content) {
      if (block.type !== "tool_use") continue;
      try {
        const out = await execute(block.name, block.input);
        toolResults.push({
          type: "tool_result",
          tool_use_id: block.id,
          content: JSON.stringify(out),
        });
      } catch (err) {
        toolResults.push({
          type: "tool_result",
          tool_use_id: block.id,
          is_error: true,
          content: String(err),
        });
      }
    }

    messages.push({ role: "user", content: toolResults });
  }

  throw new Error("Agent did not finish within " + MAX_TURNS + " turns");
}
The polling loop happens inside the agent
Claude will call verdacert_get_status repeatedly until status reaches ready, then call verdacert_get_result. You can shorten the wait by making the executor sleep + return synchronously, or you can let the loop spin (each round-trip is one Claude turn, one Verdacert call). For long-running jobs, prefer webhooks + a separate notification path instead of in-loop polling — the agent loop isn't the right tool for "wait 90 seconds."

Run it

Drop this into main.ts and npx tsx main.ts:

// main.ts
import { runAgent } from "./agent";

const text = await runAgent(
  "I have a 3-page Farsi birth certificate at " +
  "https://example.com/cert.pdf that I need certified for USCIS. " +
  "Quote it, submit it (use idempotencyKey='paralegal-demo-001'), " +
  "poll until ready, and tell me the certified PDF URL plus the " +
  "public verification URL.",
);
console.log(text);

You'll see Claude call get_capabilities quote → confirm the price in text → submit → loop on get_status until readyget_result → respond with the URLs.

Cost control

An agent loop can run a lot of tool calls. A few habits keep costs predictable.

  • Hard cap the loop with MAX_TURNS— 20 is generous for this workload; most flows finish in < 10.
  • Cache get_capabilities at the executor level (1h TTL). The model will re-call it if your system prompt tells it to; the executor returns cached data instead of hitting Verdacert.
  • Throttle status polling in the executor: add a sleep(15_000) before each get_statusso the model doesn't loop tightly. Or short-circuit: if last status was < 30s ago, return cached.
  • Use webhooks for long jobs. If your real workload has > 5 min job times, don't keep an agent in-loop polling; persist the jobId, exit the loop, and resume in a new agent invocation when the order.ready webhook fires.

Where to next

Get instant quotePricing