---
title: "Extract structured data from text"
description: "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."
section: "guides"
status: "Building for 0.2"
spec: "schema"
canonical: "https://mainsdk.dev/docs/guides/extract-structured-data"
updated: "2026-10-28"
---

# 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.

Status: Building for 0.2 · Canonical: https://mainsdk.dev/docs/guides/extract-structured-data · 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](https://mainsdk.dev/docs/getting-started/thirty-minutes)).
- 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.

```yaml title="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.

```ts title="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.

```ts title="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

```ts title="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

| Code | Cause | Fix |
| --- | --- | --- |
| `MAIN-SCHEMA-0002` | The email is longer than the `maxLength` you set (413). Nothing ran | Reject or truncate long emails upstream, or raise `maxLength` on purpose |
| `MAIN-SCHEMA-0003` | The template has a nested quantifier and is refused when defined (500) | Rewrite it so no repeated group sits inside another repeat |
| `MAIN-AI-0011` | The model's reply still does not match after its repairs; no value is returned | Loosen the schema or the prompt, and never use the raw reply |
| `MAIN-AI-0007` | The `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-0002` | The model is not in `ai.models` | Add it to `ai.models`, or call an allowed model |
| `MAIN-AI-0014` | `repairs` is not 0, 1 or 2 (400); no request was sent | Pass 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

- [Schema and parsers](https://mainsdk.dev/docs/interfaces/schema): every schema type, every parser and the guards.
- [AI calls](https://mainsdk.dev/docs/interfaces/ai): `ai.extract`, `ai.classify`, `ai.summarize` and `ai.extractor`.
- [Declare what your app uses](https://mainsdk.dev/docs/guides/declare-what-your-app-uses): the hosts and programs the inbound route touches.
