Skip to main content
A contract is the core abstraction in Boundary. It pairs a schema (what the data looks like) with rules (whether the data can be trusted). Together, they form the acceptance boundary between your LLM and your application.

Defining a contract

import { z } from "zod";
import { defineContract } from "@boundary/contract";

const leadContract = defineContract({
  schema: z.object({
    tier: z.enum(["hot", "warm", "cold"]),
    score: z.number().min(0).max(100),
    reason: z.string(),
  }),
  rules: [
    (lead) => lead.tier === "hot" ? lead.score > 70 : true,
    (lead) => lead.reason.length > 0 || "reason cannot be empty",
  ],
});
defineContract returns a DefinedContract<T> — a reusable object you call .accept() on whenever you want to run an LLM call through this contract.

Using a contract

The .accept() method takes your LLM call as a function. You own the LLM call. Boundary owns the validation.
const result = await leadContract.accept(async (attempt) => {
  const res = await openai.responses.create({
    model: "gpt-4.1",
    input: [
      "Score this lead based on their recent activity",
      ...attempt.repairs,
    ],
    text: { format: { type: "json_schema", schema } },
  });
  return res.output_text;
});
The attempt object gives you everything you need:
FieldTypeDescription
attemptnumberCurrent attempt number (1-indexed)
maxAttemptsnumberMaximum retries allowed
instructionsstringAuto-generated prompt from your schema
repairsMessage[]Targeted fix messages from prior failures (empty on first attempt)

The RunFn

Your function must match this signature:
type RunFn = (attempt: ContractAttempt) => Promise<string | null>
Return the raw LLM response as a string. Boundary handles parsing, validation, and repair. Return null if the LLM returned nothing.
The RunFn must return a string, not a parsed object. Extract the text content from your provider’s response object before returning.

Your existing Zod schemas work

Boundary uses Zod for schema validation. If you already have Zod schemas in your codebase, pass them directly — no migration, no rewrite, no Boundary-specific DSL.
// your existing schema
const invoiceSchema = z.object({
  subtotal: z.number(),
  tax: z.number(),
  total: z.number(),
});

// just add rules
const invoiceContract = defineContract({
  schema: invoiceSchema,
  rules: [
    (d) => Math.abs(d.subtotal + d.tax - d.total) < 0.01
      || `subtotal + tax != total (${d.subtotal} + ${d.tax} = ${d.subtotal + d.tax}, got ${d.total})`,
  ],
});

Configuration options

defineContract accepts these options:
OptionTypeDefaultDescription
schemaZodType<T>requiredYour Zod schema
rulesRule<T>[][]Domain correctness rules
retryRetryOptions{ maxAttempts: 3 }Retry behavior on failure
repairsRepairOverridesCustom repair strategies per failure category
instructions{ suffix?: string }Append text to the auto-generated schema prompt
loggerContractLogger<T>Hook into execution events
debugbooleanfalseEnable console logging

Next steps

Rules

Write domain correctness rules

The Repair Loop

How Boundary turns failures into fixes