Skip to main content
Claude can return text or tool results. Boundary validates the value that comes back, repairs fixable failures, and gives your app one decision: accepted or rejected.

Install

npm install @withboundary/contract zod @anthropic-ai/sdk

Define the contract

import { z } from "zod";
import { defineContract } from "@withboundary/contract";
import Anthropic from "@anthropic-ai/sdk";

const anthropic = new Anthropic();

const invoiceSchema = z.object({
  subtotal: z.number(),
  tax: z.number(),
  total: z.number(),
  currency: z.string(),
});

const invoiceContract = defineContract({
  name: "invoice-extraction",
  schema: invoiceSchema,
  rules: [
    {
      name: "invoice_math_consistent",
      description: "Subtotal plus tax must equal total within a cent",
      fields: ["subtotal", "tax", "total"],
      check: (invoice) => {
        const expected = invoice.subtotal + invoice.tax;
        return Math.abs(expected - invoice.total) < 0.01
          || `subtotal ${invoice.subtotal} plus tax ${invoice.tax} is ${expected}, but total is ${invoice.total}`;
      },
    },
  ],
});

Call Claude inside accept()

attempt.repairs are generic { role, content } messages. Map them into Anthropic’s message type before sending the retry.
const result = await invoiceContract.accept(async (attempt) => {
  const response = await anthropic.messages.create({
    model: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-20250514",
    max_tokens: 1024,
    system: attempt.instructions,
    messages: [
      {
        role: "user",
        content: `Extract invoice totals from this text:\n\n${invoiceText}`,
      },
      ...attempt.repairs.map((repair) => ({
        role: repair.role as "user" | "assistant",
        content: repair.content,
      })),
    ],
  });

  const block = response.content[0];
  return block?.type === "text" ? block.text : null;
});
The RunFn must return the raw JSON string, or null if the model did not provide text. Boundary owns parsing and validation.

Tool use

If you already use Claude tool use for structure, return the tool input as a JSON string and let Boundary enforce the rules.
const result = await invoiceContract.accept(async (attempt) => {
  const response = await anthropic.messages.create({
    model: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-20250514",
    max_tokens: 1024,
    system: [
      "Extract invoice data. Use the provided tool.",
      attempt.instructions,
    ].join("\n\n"),
    messages: [
      { role: "user", content: invoiceText },
      ...attempt.repairs.map((repair) => ({
        role: repair.role as "user" | "assistant",
        content: repair.content,
      })),
    ],
    tools: [
      {
        name: "extract_invoice",
        description: "Extract invoice totals",
        input_schema: {
          type: "object",
          additionalProperties: false,
          properties: {
            subtotal: { type: "number" },
            tax: { type: "number" },
            total: { type: "number" },
            currency: { type: "string" },
          },
          required: ["subtotal", "tax", "total", "currency"],
        },
      },
    ],
    tool_choice: { type: "tool", name: "extract_invoice" },
  });

  const toolBlock = response.content.find((block) => block.type === "tool_use");
  if (toolBlock?.type !== "tool_use") return null;

  return JSON.stringify(toolBlock.input);
});

Gate side effects

if (result.ok) {
  await accounting.createEntry(result.data);
  return { status: "accepted", invoice: result.data };
}

await invoiceReviewQueue.add({
  sourceDocumentId,
  reason: result.error.message,
  attempts: result.error.attempts,
});

return { status: "needs_review" };
Use the model you already trust for the task. Boundary is not a model router; it is the deterministic check after the model returns structured data. Next: Invoice extraction for a complete use case, or Local development for testing the contract without provider calls.