Teaching it a new job

A workflow is a checking job: “for this kind of client, these documents must show up, cover these months, and their numbers must agree.” Here’s what it takes to teach the system a new one.

A workflow is a recipe

Three ingredients — slots (which documents), a period (which months), and rules (which numbers must agree) — and a verdict at the end. The AI fills the forms; plain rules decide the verdict, so the same facts always give the same answer.

A recipe card with three ingredient rows — slots, period, rules — and a verdict card with three stamps: complete, incomplete, needs review, decided by plain rules and never by an AI.
Under the hood

A WorkflowDef in lib/workflows/registry.ts: RequiredSlots, PeriodRules and ConsistencyRules over extracted fields. Three rule kinds: reconcile (two figures agree, default tolerance half a cent), continuity (each statement’s closing equals the next’s opening), unique (the same invoice can’t count twice). Verdicts: complete / incomplete / needs_review. Severity is chosen by false-positive rate — a rule that cries wolf trains reviewers to dismiss it, and a dismissed error is worse than a warning.

One name ties everything together

Every document kind has exactly one name, like rent_summary — and the same exact word appears on the form, on the slot, and on the document itself. That one name is the whole wiring.

A luggage tag reading rent_summary connected by strings to three cards: the form (extraction schema), the slot (what the workflow accepts), and the document (the type a person confirmed).
Under the hood

documents.type is the join key: the registry key in EXTRACT_SCHEMAS, the schema’s name, and the slot’s acceptsTypes entry must be identical. A document can only be typed to a registered schema — so the form must exist before any file can carry the type. The field-path contract test (lib/workflows/field-paths.test.ts) resolves every rule operand against the Zod schema, so a renamed field fails the build instead of silently sending slots to requires_review.

Six steps, in order

Name the kinds, design each form, pick the reader, write the recipe — then files pick themselves by type, never by date (a date doesn’t exist until the form is filled), and the report tells you the rest. The forms are the hard part; the recipe is usually a page of code.

Six numbered steps: name the kinds, design each form with plain-word descriptions and permission to say I couldn't find it, pick the reading engine deliberately, write the recipe, files pick themselves by confirmed type never by date, then run and read the report.
Under the hood

Per type: a Zod schema in lib/substrate/extracts/schemas/ where every .describe() is the model’s per-field instruction and .nullable() is the licence to say “not found” instead of hallucinating; an instruction prompt (ISO dates, numeric amounts, “return null, do NOT guess”); and a required engine choice — ai-sdk for flat text, llamaextract for tables and layout. A schema describes what a document means, not how it looks: 27 banks, one bank_statement schema. Selection is computeSelection — one pure function shared by every lane, so there is no second opinion about “in period”. Re-typing a document purges its old extract; regex is never used for extraction.

Prove it works — by breaking it

The tests that matter are the unfriendly ones: a fixture built from a real document with its defects kept in, a bent number the rule must catch, a tripwire for renamed fields — and one real file walked through the Verify tab by a person.

Three cards: break it on purpose (perturb a real fixture and the rule must fire), the rename tripwire (field paths checked against the form), and open one real file end to end in the Verify tab.
Under the hood

Vitest, not the eval harness: a <workflow>.test.ts where each rule passes on real reconciled figures and fails on a perturbed copy — no LLM, no I/O, milliseconds — plus pnpm typecheck / lint / test. Watch for the classic traps the guide documents: an optional slot’s defects still block; a missing figure means skip, not fail; trivially-passing rules (everything zero) are a typing smell; and the most expensive lesson in the repo — a filtered transaction export typed as bank_statement put a file out by exactly 230.00 — was fixed with a new type, not a tolerance.