API documentation

Recipe: AI immigration paralegal agent

A working agent that reads an immigration case file, prices a certified translation for every foreign-language document, asks before it spends money, places the orders, and hands back the certificate for each one. Two ways to run it: Claude Desktop with no code, or about 200 lines of TypeScript on the Vercel AI SDK.

What the agent does

Every step is a call to Verdacert's public v1 API. Nothing here needs a special agreement to ship.

StepToolWhat happens
1getCapabilitiesGrounds the agent in the live languages, document types and speed tiers.
2quotePrices each document: page count, language, use case, speed tier.
3ask_user_to_confirmShows the total and the ETA and waits for a yes. Your own UI goes here in a product.
4submitPlaces one order per document, each with its own idempotency key.
5getStatus + waitPolls each job, backing off between polls, until it is ready.
6getResultReturns the certified PDF URL, certificate ID, signed JWS receipt and public verify URL.

Against a sandbox key the whole run takes two to three minutes and costs nothing: sandbox jobs walk a deterministic state machine with no translator in the loop. See Sandbox.

Path 1: Claude Desktop, no code

Point Claude Desktop at Verdacert's MCP server and paste a case description.

  1. Get a sandbox key (vc_sandbox_…) at /onboarding.
  2. Add this to Claude Desktop's config file (~/Library/Application Support/Claude/claude_desktop_config.json on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows) and replace the placeholder key.
  3. Quit and reopen Claude Desktop. The Verdacert tools appear in the tools menu.
{
  "mcpServers": {
    "verdacert": {
      "url": "https://www.verdacert.com/api/mcp",
      "headers": {
        "Authorization": "Bearer vc_sandbox_REPLACE_ME"
      }
    }
  }
}

Then open a conversation and paste:

You're an immigration-paralegal assistant. I'm preparing an I-130
petition for a client. They've provided three Farsi documents:

  - Iranian birth certificate (4 pages, https://example.com/akbari-birth.pdf)
  - Iranian marriage certificate (2 pages, https://example.com/akbari-marriage.pdf)
  - Iranian police clearance (1 page, https://example.com/akbari-pc.pdf)

Quote certified translations for USCIS at the standard tier, total the
cost, ask my confirmation, then submit. Poll for completion and give me
the final certificate verify URLs.
Use www in the URL
Configure https://www.verdacert.com/api/mcp. The bare domain redirects to www, and MCP clients drop the Authorization header when a redirect changes origin.

Path 2: the programmatic agent

Vercel AI SDK for the tool-calling loop, @verdacert/ai-sdk-tools for the seven typed Verdacert tools, and Claude as the model.

Set up the project

Make an empty folder with the four files below, then add agent.ts from the next section and a sample-case/CASE.md.

mkdir immigration-paralegal && cd immigration-paralegal
# add package.json, pnpm-workspace.yaml, .env and agent.ts (below)
pnpm install
pnpm start                       # uses sample-case/CASE.md
pnpm start path/to/your/case.md  # or your own case file
pnpm start --yes                 # skip the confirmation prompt

package.json:

{
  "name": "verdacert-example-immigration-paralegal",
  "version": "0.2.0",
  "private": true,
  "description": "Reference AI agent that prepares Verdacert certified translations for an immigration case file. Uses the Vercel AI SDK + AI Gateway + @verdacert/ai-sdk-tools.",
  "type": "module",
  "scripts": {
    "start": "tsx agent.ts"
  },
  "dependencies": {
    "@verdacert/ai-sdk-tools": "^0.1.0",
    "ai": "^6.0.0",
    "dotenv": "^17.4.2",
    "zod": "^4.0.0"
  },
  "optionalDependencies": {
    "@ai-sdk/anthropic": "^3.0.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "tsx": "^4.21.0",
    "typescript": "^5"
  }
}

pnpm-workspace.yaml lets pnpm build esbuild, which tsx needs. Without it, pnpm 11 refuses every pnpm start:

# Standalone workspace root, so the example installs on its own and pnpm
# reads these settings from here rather than from the Verdacert repo.
#
# tsx bundles esbuild, whose postinstall fetches its native binary. pnpm 10+
# skips unapproved build scripts, and pnpm 11 then fails every `pnpm start`
# with ERR_PNPM_IGNORED_BUILDS until the build is allowed.
allowBuilds:
  esbuild: true

.env, copied from this template:

# --- Verdacert ----------------------------------------------------
# Sandbox key for risk-free runs (no real translation, no charge).
# Mint one at https://www.verdacert.com/onboarding.
VERDACERT_API_KEY=vc_sandbox_REPLACE_ME

# Default: https://www.verdacert.com. Override for local dev.
# VERDACERT_BASE_URL=http://localhost:3000

# --- Model provider (pick one) ------------------------------------
# Vercel AI Gateway — recommended. Cheaper, observable, multi-provider
# failover. On Vercel deployments this is auto-injected; locally,
# grab one at https://vercel.com/dashboard/ai-gateway.
AI_GATEWAY_API_KEY=

# Falls back to direct Anthropic when AI_GATEWAY_API_KEY is empty.
# https://console.anthropic.com/
# ANTHROPIC_API_KEY=

agent.ts

The whole agent. The interesting parts are the tool set (the published tool catalog plus a confirmation tool and a wait tool) and the system prompt that orders the steps.

/**
 * Immigration paralegal — reference Verdacert agent.
 *
 *   pnpm install
 *   cp .env.example .env  # add your keys
 *   pnpm tsx agent.ts [path/to/case.md]
 *
 * Walks a paralegal through preparing a certified-translation case file:
 *
 *   1. case intake (free-text description of a client + their docs)
 *   2. quote (Verdacert.quote per doc)
 *   3. confirmation prompt (terminal y/n) — the agent calls
 *      `ask_user_to_confirm` before submitting paid orders
 *   4. submit (Verdacert.submit per doc)
 *   5. polling to completion (Verdacert.getStatus)
 *   6. delivery (Verdacert.getResult — certificate URLs + JWS)
 *
 * Stack: AI SDK + Vercel AI Gateway (default) + `@verdacert/ai-sdk-tools`
 * (the typed tool catalog for Verdacert's v1 API). Falls back to direct
 * Anthropic if AI_GATEWAY_API_KEY isn't set.
 *
 * Sandbox keys are encouraged for first-run. The whole loop takes
 * ~2-3 minutes against a sandbox key.
 */

import { generateText, tool, stepCountIs, type ModelMessage } from "ai";
import { z } from "zod";
import { createVerdacertTools } from "@verdacert/ai-sdk-tools";
import { readFileSync, existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createInterface } from "node:readline";

// Override so an empty value pre-injected by a parent shell or pnpm
// hook doesn't suppress what's in .env.
import { config as dotenvConfig } from "dotenv";
dotenvConfig({ override: true });

const VERDACERT_BASE = (
  process.env.VERDACERT_BASE_URL ?? "https://www.verdacert.com"
).replace(/\/+$/, "");
const VERDACERT_KEY = process.env.VERDACERT_API_KEY;
const AI_GATEWAY_KEY = process.env.AI_GATEWAY_API_KEY;
const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY;

if (!VERDACERT_KEY) {
  console.error("VERDACERT_API_KEY is not set. See .env.example.");
  process.exit(1);
}
if (!AI_GATEWAY_KEY && !ANTHROPIC_KEY) {
  console.error(
    "Need AI_GATEWAY_API_KEY (preferred) or ANTHROPIC_API_KEY. See .env.example.",
  );
  process.exit(1);
}

// Model selection. AI Gateway is the recommended path on Vercel —
// observable, rate-limited, multi-provider failover — and is picked
// up automatically when AI_GATEWAY_API_KEY is present in the env.
// Provider strings ("anthropic/claude-sonnet-5") route through the
// gateway. If the gateway key is missing we fall back to direct
// Anthropic via @ai-sdk/anthropic (lazy-import below).
const USE_GATEWAY = !!AI_GATEWAY_KEY;
const MODEL_ID = USE_GATEWAY
  ? "anthropic/claude-sonnet-5"
  : "claude-sonnet-5";

// ---- Tool catalog -----------------------------------------------

// All seven Verdacert tools — typed, schema-defined, wired to the
// REST API. Pass `apiKey` once at construction time; every tool
// invocation forwards it as a Bearer token.
const verdacertTools = createVerdacertTools({
  apiKey: VERDACERT_KEY,
  baseUrl: VERDACERT_BASE,
});

// Plus one ad-hoc tool for the CLI flow: a human-in-the-loop confirm
// prompt the agent must call before submitting paid orders. This is
// where you'd plug your own UI in a real product.
const rl = createInterface({ input: process.stdin, output: process.stdout });
function ask(q: string): Promise<string> {
  return new Promise((res) => rl.question(q, (a) => res(a.trim())));
}
let autoYesFlag = false;

const confirmTool = tool({
  description:
    "Pause and ask the human paralegal to confirm a step (e.g. before submitting paid orders). Returns 'yes' or 'no'.",
  inputSchema: z.object({
    question: z.string().describe("The yes/no question to ask the human."),
  }),
  execute: async ({ question }) => {
    if (autoYesFlag) return { answer: "yes" };
    const a = (await ask(`\n[AGENT ASKS] ${question} (y/n) `)).toLowerCase();
    return { answer: a.startsWith("y") ? "yes" : "no" };
  },
});

// A model cannot sleep between tool calls on its own, so without this it
// polls getStatus back-to-back and burns its poll budget in seconds while
// a job takes minutes.
const waitTool = tool({
  description:
    "Wait before polling getStatus again. Use it between polls: start at 15 seconds and double, up to 60.",
  inputSchema: z.object({
    seconds: z.number().int().min(5).max(60).describe("How long to wait."),
  }),
  execute: async ({ seconds }) => {
    await new Promise((res) => setTimeout(res, seconds * 1000));
    return { waited: seconds };
  },
});

const tools = {
  ...verdacertTools,
  ask_user_to_confirm: confirmTool,
  wait: waitTool,
};

// ---- Read the case file -----------------------------------------

const caseArg = process.argv[2];
const autoYesArg = process.argv.includes("--yes") || process.argv.includes("-y");
autoYesFlag = autoYesArg;

let caseDescription: string;
if (caseArg && existsSync(caseArg)) {
  caseDescription = readFileSync(resolve(caseArg), "utf8").trim();
} else {
  caseDescription = readFileSync(
    // An ES module has no __dirname (package.json sets "type": "module").
    resolve(dirname(fileURLToPath(import.meta.url)), "sample-case", "CASE.md"),
    "utf8",
  ).trim();
}

// ---- System prompt ----------------------------------------------

const systemPrompt = `\
You are an immigration-paralegal assistant working with Verdacert,
a USCIS-certified translation service. The human paralegal will
hand you a case description with one or more source-language
documents that need certified English translations.

Your job, end-to-end:
  1. Identify each document that needs translating. Decide source
     language, document type, page count, use case, and speed tier.
  2. Call getCapabilities once at the start to ground your choices.
  3. For each document: call quote() and present the price + ETA to
     the user as a single combined summary.
  4. Call ask_user_to_confirm before any paid submit() — give them
     the total price and ETA in the question.
  5. If they confirm, call submit() per document with a fresh
     idempotencyKey per document (uuids are fine).
  6. Poll getStatus() until each job is ready or delivered, calling
     wait() between polls (15s, then 30s, then 60s). Give up after
     12 polls per job.
  7. Call getResult() to fetch the certified PDF URL + JWS.
  8. Summarise: per document, the certificate id, the PDF URL, the
     JWS receipt, and the public verify URL.

Sandbox keys run a state machine with no real translator in the loop:
a sandbox job reaches ready in about two minutes. Live keys take hours,
up to the speed tier's SLA.

Be terse. Don't narrate every step. The paralegal wants outcomes,
not chatter.
`;

// ---- Run --------------------------------------------------------

async function main() {
  console.log(
    `\n→ Verdacert paralegal agent · model=${MODEL_ID} ${USE_GATEWAY ? "(via AI Gateway)" : "(direct Anthropic)"}\n`,
  );

  // Lazy provider import so the @ai-sdk/anthropic dep is only required
  // when a user is in direct-Anthropic mode.
  const model = USE_GATEWAY
    ? MODEL_ID
    : (await import("@ai-sdk/anthropic")).anthropic(MODEL_ID);

  const messages: ModelMessage[] = [
    { role: "user", content: `Case file:\n\n${caseDescription}` },
  ];

  const { text, steps } = await generateText({
    model,
    system: systemPrompt,
    messages,
    tools,
    // 40 steps covers a 1-3 document case with its waits and polls
    // (parallel tool calls share a step). Per-step it's tool-call →
    // tool-result → next tool-call etc.
    stopWhen: stepCountIs(40),
  });

  console.log("\n──── Agent summary ────\n");
  console.log(text);
  console.log(`\n(${steps.length} step(s) total)\n`);

  rl.close();
}

main().catch((err) => {
  console.error("\nAgent failed:", err);
  rl.close();
  process.exit(1);
});

sample-case/CASE.md

# Sample case — Akbari I-130

A representative I-130 petitioner client with foreign-language vital records.
Used as the default case description when the agent runs without stdin input.

**Client:** Reza Akbari
**Filing:** I-130 (Petition for Alien Relative) for spouse
**Documents needing certified translation:**

1. Iranian birth certificate (Shenasnameh)
   PDF URL: https://example.com/akbari-birth.pdf
   Pages: 4
2. Iranian marriage certificate
   PDF URL: https://example.com/akbari-marriage.pdf
   Pages: 2
3. Iranian police clearance
   PDF URL: https://example.com/akbari-police-clearance.pdf
   Pages: 1

All documents are in Farsi. Filing deadline is in 6 weeks — standard SLA is fine.

Taking it to production

The example is deliberately small. These are the changes a real product makes.

  • Document upload. The agent passes publicly reachable URLs. In a product, upload to your own storage and pass short-lived signed URLs.
  • Webhooks instead of polling. Pass webhookUrl on submit and Verdacert posts signed state changes. See Webhooks.
  • End-user attribution. Send your user's ID as endUser.externalId so refunds route back to the right account. See Multi-tenant SaaS.
  • Live billing. Sandbox is free. A live key charges the card on file when submit runs, which is why the agent must confirm first. See Production checklist.
  • Streaming UI. Swap generateText for streamText to show progress in a chat interface. See Vercel AI SDK.

Where to next

Get instant quotePricing