MainSDK
Search⌘K
How-to

Extract structured data from text

Turn an inbound email into a typed invoice. Try a pattern first, fall back to a model when it misses, and check both with one schema.

Building for 0.2

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

An invoice email usually follows a pattern, and a model is slow and costs money. This guide reads each inbound email with a regular expression first. When the pattern misses, it asks ai.extract for the same typed invoice. Both paths check the result against one schema, and the caller gets the invoice, the path it came from (via) and the model cost, if any. It takes about 25 minutes.

You need

  • A MainSDK app with a main.yaml (see Thirty-minute path).
  • A model key for the anthropic driver, or the local driver for development. The local driver needs no key.
  • Three sample emails: one that follows the template, one that does not, and one longer than 20 000 characters.

1. Declare the model and its limit

Add an ai block with a per-feature spend limit. The model is only called when the pattern misses, so the limit is the cost you accept for that case.

main.yaml
ai:
  driver: anthropic
  key: secret:ANTHROPIC_API_KEY        # a secret reference, never a literal
  models: [anthropic/claude-*]
  data: { training: never, retention: zero }
  limits:
    per-feature: { invoice-intake: $50/month }
    total: $200/month

For development, set driver: local in main.env.dev.yaml. The fake replies without a key.

2. Describe the invoice once

Write the invoice as a schema. Both paths use it, so the pattern and the model cannot disagree about the shape.

lib/invoice.ts
import { s, type Infer } from "mainsdk/schema";

export const Invoice = s.object({
  number: s.string().min(1).max(40),
  vendor: s.string().min(1).max(80),
  currency: s.enum(["USD", "EUR", "INR"]),
  total: s.number().min(0),
  due: s.date(),                                      // ISO 8601 date, returned as the same string
}).refine((i) => i.total > 0, "Total must be above zero");

export type Invoice = Infer<typeof Invoice>;

3. Read the email and try the pattern first

Limit the text first with parse.text, so a huge email is refused before any pattern runs. Then read the named groups of the template with parse.regex. Its groups arrive as strings, and the schema coerces them, so total becomes a number.

lib/read-invoice.ts
import { ai, MainError } from "mainsdk";
import { parse } from "mainsdk/parse";
import { s, type Infer } from "mainsdk/schema";

// The schema from lib/invoice.ts
const Invoice = s.object({
  number: s.string().min(1).max(40),
  vendor: s.string().min(1).max(80),
  currency: s.enum(["USD", "EUR", "INR"]),
  total: s.number().min(0),
  due: s.date(),
}).refine((i) => i.total > 0, "Total must be above zero");
type Invoice = Infer<typeof Invoice>;

// Template: "Invoice INV-2026-0042 from Acme Ltd for USD 1250.00 due 2026-11-30."
const TEMPLATE = /Invoice (?<number>[A-Z0-9-]{1,40}) from (?<vendor>[^\n.]{1,80}) for (?<currency>[A-Z]{3}) (?<total>\d+(?:\.\d{1,2})?) due (?<due>\d{4}-\d{2}-\d{2})/;

export type Found = { invoice: Invoice; via: "pattern" | "model"; cost?: string };

function noMatch(err: unknown): boolean {
  return err instanceof MainError && (err.code === "MAIN-SCHEMA-0004" || err.code === "MAIN-SCHEMA-0001");
}

export async function readInvoice(email: { body: string }): Promise<Found> {
  const body = parse.text(email.body, { maxLength: 20_000 });   // trims and normalises; MAIN-SCHEMA-0002 above the limit

  try {
    const invoice = parse.regex(body, TEMPLATE, Invoice);        // MAIN-SCHEMA-0004 if the template misses
    return { invoice, via: "pattern" };                          // no model call, so no cost
  } catch (err) {
    if (!noMatch(err)) throw err;                                // any other error stops here
  }

  // Fall back to the model with the same schema.
  const found = await ai.extract({
    input: body,
    schema: Invoice,
    model: "anthropic/claude-sonnet-4-5",
    feature: "invoice-intake",
    repairs: 1,                                                  // one repair after an invalid reply; the default
  });
  return { invoice: found.value, via: found.via, cost: found.cost };
}

The regex has no repeated group inside another repeat, so it passes the check at definition. A pattern such as (\d+)+ would be refused with MAIN-SCHEMA-0003.

4. Validate once, in the schema

Both paths return a value that the schema has already checked. A model reply that does not match is repaired, and the call fails with MAIN-AI-0011 if it still does not match, so readInvoice never returns an invalid invoice. The refine rule runs on the pattern path too, because parse.regex checks its groups with the same schema.

Keep business rules in the schema, not after the call, so both paths obey them.

5. Use it in a route

app/api/inbound-invoice/route.ts
declare function readInvoice(email: { body: string }): Promise<{ invoice: unknown; via: "pattern" | "model"; cost?: string }>;   // lib/read-invoice.ts

export async function POST(req: Request) {
  // Check the mail provider's signature before you read the body. See the webhooks interface.
  const email = (await req.json()) as { body: string };
  const { invoice, via, cost } = await readInvoice(email);

  return Response.json({ invoice, via, cost: cost ?? null });   // via says which path ran
}

via is pattern when the template matched, with no cost, and model when the model read the email. cost is the Money string of the model call.

Verify

Send the three sample emails through the route, or call readInvoice from a script:

  • The template email returns via: "pattern" and no cost.
  • The free-text email returns via: "model" and a cost, and its invoice passes the schema.
  • The long email is refused with MAIN-SCHEMA-0002 before any pattern or model call.

Then run the check:

bash
npx mainsdk check --json --agent

It exits 0 when every interface is declared and the ai block passes its rules.

If it fails

CodeCauseFix
MAIN-SCHEMA-0002The email is longer than the maxLength you set (413). Nothing ranReject or truncate long emails upstream, or raise maxLength on purpose
MAIN-SCHEMA-0003The template has a nested quantifier and is refused when defined (500)Rewrite it so no repeated group sits inside another repeat
MAIN-AI-0011The model's reply still does not match after its repairs; no value is returnedLoosen the schema or the prompt, and never use the raw reply
MAIN-AI-0007The invoice-intake limit or the total limit is reached; nothing reached the model (403)Raise the limit on purpose, or let the email wait for the period to reset
MAIN-AI-0002The model is not in ai.modelsAdd it to ai.models, or call an allowed model
MAIN-AI-0014repairs is not 0, 1 or 2 (400); no request was sentPass 0, 1 or 2

For agents

text
Goal: turn inbound invoice emails into typed invoices. Try the template with parse.regex first; use ai.extract only when it misses.
Read: docs/specs/schema.md, docs/specs/ai.md (ai.AC11 to ai.AC12), this page, main.yaml.
Touch only: main.yaml (the ai block), lib/invoice.ts, lib/read-invoice.ts, the inbound route.
Do: define Invoice with s; keep one schema for both paths; catch only MAIN-SCHEMA-0001 and MAIN-SCHEMA-0004 around parse.regex; return via and cost.
Verify: npx mainsdk check --json --agent, and the three sample emails in Verify.
Never parse model text by hand, never skip the schema on the model path, never raise a limit to pass a test.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet