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