Extraction schemas
When a customer sends us bank statements, there wasn't a standard answer for what should come back. Customers invent their own field names, and if we don't know what someone wants, we don't really have a default to offer them. So I set out to define one for each document type, starting with bank statements.
Look at what customers already built
I knew we had hundreds of configs we'd already built for customers, so instead of designing a schema from scratch I analyzed those: which fields people actually pull from a bank statement, what they call them, and how they structure the output. The end date alone showed up under more than a dozen names.
statement_period_end236 configsstatement_period_start282 configsaccount_number206 configsaccount_holder183 configsSee how everyone else structures it
Then I looked at how banking data standards and other document AI companies handle the same thing, like whether accounts are always a list and whether a transaction has one signed amount or separate debit and credit columns. That turned into a baseline for what a statement should come back as.

My first naming convention was wrong
My first pass named everything entity first, like balance_opening, because it sorted nicely. When I checked it against the configs, nobody had ever written balance_opening, and dozens of them said opening_balance. So I switched to the names people actually use and moved repeating rows into sections, which made the prefixes pointless anyway.
one field, a dozen namesTest each config against real statements
A schema only helps if the configs actually return it. So there's a scorecard that runs each bank's config against every statement we have from that bank and checks that the transactions add up to the totals. Agents built configs for the banks we were missing, and nothing they made went live until it passed.
def check_account(a, start, end):
tx = a.get("transactions") or []
got = [num(t.get("amount")) for t in tx if num(t.get("amount")) is not None]
o, c = num(a.get("opening_balance")), num(a.get("closing_balance"))
if o is not None and c is not None and tx:
# opening + every transaction should land exactly on closing
r["reconciles"] = abs(o + sum(got) - c) < TOL and len(got) == len(tx)
dep = num(a.get("total_deposits"))
if dep is not None and tx:
r["deposits_match"] = abs(sum(x for x in got if x > 0) - dep) < TOL
# and each row's balance should follow from the one before it
ch = chain_rate(tx)Check whether it carries over, then ship it
To see if the shape worked for other documents, I read configs across a lot of other industries. They split into two kinds, ledgers with rows and totals and forms with flat fields, so the guidelines have a version for each. Then the schemas, checks and sample files went into a kit people can download.


New bank configs get built to the schema, and the kit is out for anyone building a document pipeline.