Founders

How to Write a Finance Agent That Reads Your Bank Export

Antonio Evans11 min read

A desk-level scene: an open laptop showing a bank CSV import, rows highlighted in two colors (routine categorized spend and flagged acquisition spend), and a paper tray beside it labeled for human review.
Key points

Key points

  1. 1Build the agent as three layers: a deterministic parser, a schema-constrained model call, and a review gate before anything posts to the ledger.
  2. 2Normalize columns, deduplicate on the bank's transaction ID, and prove the opening balance plus the sum of rows equals the closing balance before a single model call happens.
  3. 3Force the model to return category, counterparty, confidence, and an acquisition flag with a reason for every row, validated against a JSON Schema.
  4. 4Route every row below your confidence threshold and every acquisition flag to a human queue, and write an immutable audit record for each decision.

Why the deterministic layer comes before the model

The most common failure in bank-export agents is asking a model to do work a parser should do. Bank files are semi-structured: column order shifts between exports, headers carry different names ("Description" versus "Memo" versus "Narrative"), and amount sign conventions differ between institutions. A model is bad at this and will occasionally hallucinate a column mapping that looks plausible. A parser is good at it and fails loudly when it cannot find a column.

The deterministic layer has four jobs, in order.

Read and normalize. Map every incoming column to a canonical set: txn_id, posted_date, amount, currency, description, counterparty_raw, account_id, balance_after. Coerce dates to ISO 8601, store amounts in integer minor units (cents), and record the source file name, the source row number, and a hash of the raw row. Storing the raw row hash means that when a human disputes a categorization six months later, you can show exactly what the bank sent.

Deduplicate on the bank's transaction ID. Banks reuse export windows: a user pulls 30 days on Monday and 45 days on Thursday, and the overlap arrives twice. Deduplicate on the bank's own ID, not on a hash of date plus amount plus description. Two legitimate identical charges from the same merchant on the same day are common (two coffees, two ad-platform threshold charges), and hashing on content will silently drop one. If the export has no ID column, synthesize a stable key from account_id, posted_date, amount, the ordinal position of that row within the day, and a hash of the description, and mark those rows as lower confidence in the parser.

Reconcile the balances. This is the control that catches almost everything else. Take the opening balance stated on the export, add every parsed amount, and compare the result to the stated closing balance. If they differ, stop. Do not call the model. A mismatch means a dropped row, a sign flip, a duplicated line, or a truncated file, and no amount of model cleverness fixes an arithmetic error. Treat reconciliation as a hard gate, the same way a reconciliation page insists the statement total equals the balance to reconcile before it allows a post in Business Central. Microsoft also notes there that unmatched lines left on the reconciliation page represent discrepancies you must resolve, which is exactly the posture the parser should take.

Emit a stable, typed row set. The parser's output is a JSON array of canonical rows keyed by txn_id. Everything downstream addresses rows by that key only.

Ad platforms make the same point from the other direction: threshold charges do not align to campaign dates or calendar months, and a single card transaction can span two campaigns and two reporting periods according to Opal's reconciliation glossary. That is a reason to treat the amount as the ground truth and the description as merely evidence, and to expect one deposit or charge to need splitting later.

A worked check, with numbers:

StepValue
Opening balance on the export48,210.55
Sum of 312 parsed rows (net)-11,904.12
Opening plus sum36,306.43
Closing balance stated by the bank36,306.43
Difference0.00, parser passes

Now suppose one row failed to parse because its description contained an embedded newline that broke a line-oriented reader. The sum falls short by that row's amount, the difference is nonzero, and the run halts with a parse error naming the source row number. That is the behavior you want: a loud, specific failure instead of a silent miscount that reaches the ledger.

Asking the model with a strict JSON Schema

Once the rows are canonical and reconciled, the model's job narrows to judgment: what is this counterparty, which account does it belong to, how sure are you, and is it acquisition spend. That is a classification and extraction task, not an arithmetic one, and it is where a language model earns its place.

Use schema-constrained output rather than asking politely for JSON in the prompt. A schema turns a formatting suggestion into a contract the runtime can enforce. Snowflake's agent SDK, for example, validates the model's final output against a JSON Schema passed as output_format and returns an error result if the agent cannot satisfy the schema after retries per the Snowflake structured output docs. Databricks exposes the same idea through a response_format field with a json_schema mode, and the underlying mechanism is constrained decoding, which limits the tokens the model can emit at each step to those consistent with the expected structure as described in Databricks' writeup. AWS documents the same family of techniques for keeping outputs machine-readable in high-stakes settings, naming banking and accounting integrations among the beneficiaries in the AWS structured output post.

A schema for one row:

{
  "type": "object",
  "additionalProperties": false,
  "required": ["txn_id", "category", "counterparty", "confidence", "acquisition_flag", "reason"],
  "properties": {
    "txn_id": { "type": "string" },
    "category": {
      "type": "string",
      "enum": ["software", "advertising", "payroll", "rent", "travel",
               "professional_services", "bank_fees", "taxes", "revenue",
               "transfer", "owner_draw", "uncategorized"]
    },
    "counterparty": { "type": "string", "maxLength": 80 },
    "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
    "acquisition_flag": { "type": "boolean" },
    "acquisition_reason": { "type": ["string", "null"], "maxLength": 240 },
    "reason": { "type": "string", "maxLength": 240 }
  }
}

Three design choices in that schema matter more than the rest.

The category enum is your chart of accounts, and nothing else. Do not let the model invent categories. An open category string produces a long tail of near-duplicates ("software", "Software", "SaaS tools", "software subscription") that someone has to clean up by hand. Pin the enum to the exact account codes or names you post to, and require the model to choose uncategorized when nothing fits. In Snowflake's example the enum is expressed directly in the schema, and the same pattern applies to your accounts per the Snowflake docs. Define the account codes in a config file, version that file, and include the version in the prompt and in the audit record.

Confidence is a real number the code acts on, not a vibe. Ask for a float between 0 and 1 and use it as a routing input. Ask for it explicitly rather than inferring it: "how confident are you that this row belongs to this exact account, given only what is in this row". The reason field exists to make the number auditable; if the reason is empty or generic, treat the confidence as suspect.

The acquisition flag needs a reason or it is noise. "Acquisition spend" means spend on acquiring customers, which is a specific economic concept rather than any suspicious-looking payment. Contribution margin is revenue minus cost of goods sold minus customer acquisition cost, which is the number that tells you whether an incremental sale actually helps as David Rogg puts it for Reformation Partners. That definition is why the flag matters: miscategorize a customer acquisition cost as a general expense and your contribution margin matrix reads wrong, and every downstream breakeven calculation inherits the error. Forcing a reason string means a reviewer can see whether the model is flagging agency retainers and ad platform charges for good reasons or guessing.

Batch rows in groups of 50 to 100 with the transaction IDs visible in the input, and re-key each returned object by txn_id rather than trusting array order. Models reorder arrays. Key on the ID and discard any returned ID that was not in the input batch, then check that every input ID came back.

Budget a retry of one or two attempts for schema failures, then fall back to uncategorized with confidence 0 rather than looping. Claude Code's structured output implementation, for instance, wraps the schema as a synthetic tool call and enforces it exactly once with a bounded retry budget, feeding validation errors back to the model between attempts as Ken Huang describes.

Choosing the confidence threshold with numbers

The threshold is a business decision about the cost of a wrong post versus the cost of a review. You can set it with a small measurement instead of a guess.

Take a labeled sample of 200 rows, ideally the messiest 200 you have. Run the agent, then sort by confidence and compute accuracy in bands:

Confidence bandRows in bandCorrectAccuracy
0.95 to 1.009696100%
0.80 to 0.94615895%
0.50 to 0.79292379%
below 0.5014643%
All rows20018392%

The overall 92% looks respectable and tells you nothing about what to do. The bands do. If a wrong post costs roughly 15 minutes of a controller's time to find and reverse, and a review costs about 45 seconds, then reviewing the 0.50 to 0.94 rows is clearly worth it: you review 90 rows and avoid about 8 errors from the 0.80 to 0.94 band and 6 from the 0.50 to 0.79 band. Reviewing the below-0.50 band is even more obviously worth it. The 0.95 and above band is where automation actually pays; auto-post it and sample 5% of it each week as a tripwire.

A decision rule worth writing into the code: auto-post above the band whose measured accuracy is at least as good as your manual coding accuracy, and review everything below it. If your team codes right 97% of the time, a band at 95% does not clear the bar, no matter how impressive it feels.

Two refinements. First, the bands are per account, not global. Travel and professional services will be worse than SaaS subscriptions, because the descriptions are vaguer. Second, recalculate the bands whenever you change the chart of accounts, the model, or the prompt, because the calibration is a property of that combination.

The same measured-honesty discipline applies to the metrics the categorization feeds. CAC payback is one of those metrics, and it is frequently miscalculated. Stripe's worked example divides acquisition cost by monthly profit per customer to get the payback period in months per Stripe's explanation, while The SaaS CFO's version divides CAC by cohort gross margin dollars and multiplies by twelve when using ARR per The SaaS CFO. Different formulas, different answers from the same ledger. Your agent does not fix that ambiguity, but it does determine which rows land in the numerator, so a miscategorized ad invoice quietly changes a number someone is being measured on.

The human queue and the posting gate

The gate is deliberately boring. Two conditions send a row to review: confidence below the threshold, and acquisition_flag true, regardless of confidence. The second rule exists because acquisition spend is the category most likely to change a board-level number, and a confident wrong answer there is more expensive than a hesitant right one.

Queue design details that matter:

ElementWhat to doWhy
Sort orderLowest confidence first, then highest amountAttention goes where risk is highest
Row displayRaw description, parsed amount, suggested account, reason, and the three most similar previously approved rowsReviewers approve faster with precedent in view
ActionsApprove, change account, mark as transfer, split, mark as duplicateSplits and transfers cover real cases a single-account model cannot
Batch sizeCap the queue at what a reviewer can clear in one sittingA 900-row queue gets rubber-stamped
Escape hatchFree-text note on any changeThe note becomes future training signal

Approve-and-post should be one action, not two. If a reviewer approves a row and the post fails, the row must return to the queue with the failure visible, never vanish.

Store the reviewer's decision alongside the model's suggestion. Over time the disagreements are the highest-value data you own: they show exactly which accounts, merchants, and description patterns the prompt handles badly, and they are the natural evaluation set for the next prompt revision.

Microsoft's reconciliation workflow offers a useful precedent for the fallback path: unmatched lines stay on the page as visible discrepancies until someone resolves them, rather than being silently posted or silently dropped per the Business Central documentation. Build the queue the same way. Nothing leaves it except by a human decision or by the auto-post rule.

The audit log

Every run should produce an append-only record that answers, for any posted row: what the bank said, what the parser did with it, what the model said, what the reviewer decided, and what was posted.

Log at least these fields per row:

  • txn_id, run_id, account_id, source file name, source file hash, source row number
  • the raw row as received, byte for byte
  • the normalized row and the column mapping version used
  • the model provider, model identifier, prompt version, and chart-of-accounts version
  • the raw model output, the parsed structured output, and the validation result
  • confidence, category, counterparty, acquisition flag, and reason
  • the routing decision (auto-posted or queued) with the threshold that produced it
  • the reviewer identity, timestamp, final account, and any split lines
  • the posted journal entry id, or the posting failure

Keep the raw model output even though it is redundant with the parsed version. When a categorization looks wrong three months later, you need to know whether the model said the wrong thing or the parser mangled what it said. Those are different bugs with different fixes.

Two operational habits make the log useful rather than merely present. Write it before posting, not after, so a crash leaves a record of intent. And never mutate it; corrections append a new record that supersedes the old one by txn_id and run_id. The evaluation set for prompt improvements comes from reading these records, and an editable log destroys its own value as evidence.

Where this design pays off, and where it does not

This three-layer shape is worth building when the volume justifies review tooling and the categories are stable enough to enumerate. If you process 40 transactions a month, a spreadsheet and 20 minutes is cheaper than the agent. If you process 3,000 across several entities and currencies, the parser reconciliation check alone will save more than the model does.

It is also worth building when the categories feed a metric someone is judged on. Contribution margin, CAC payback, and burn multiple all move when a row changes account, and the definitions differ enough between sources that a miscoded row can change a conclusion even when every formula is applied correctly see Wall Street Prep's CAC payback treatment and the Golden Egg Check comparison of gross and contribution margin. Auditable categorization is the prerequisite for trustworthy unit economics, not an afterthought.

Start with the parser and the balance check, run the model in shadow mode against a month of historical exports with a labeled set to calibrate bands, and only then turn on auto-posting for the top band. If you would rather have the pipeline, the review queue, and the audit trail built and wired to your chart of accounts than assemble them yourself, you can describe the export format and the accounts to Quanta and a specialist will build it with you.

FAQ

FAQ

Do I need a model at all, or can rules do this?

Rules handle a surprising share of rows, especially recurring merchants you have seen before, and you should apply them first as a cheap pre-pass. The model earns its place on the long tail: new merchants, vague descriptions, and the acquisition judgment that depends on what a counterparty actually is rather than what string it sends. Rules also produce confidence for free (an exact merchant match is high confidence), which makes them a useful first tier before the model sees anything.

What if the export has no transaction ID column?

Synthesize a stable key from the account, the posted date, the amount, the row's ordinal position within that date, and a hash of the description, then flag those rows as parser-confidence-limited. The risk is real: two identical charges on the same day need the ordinal position to stay distinct, and reordered exports will break the key. Prefer files with IDs, and treat independent-verification of the reconciliation check as the guard against a bad synthetic key.

How do I handle transfers between my own accounts?

Handle them as a first-class category rather than letting the model guess. A transfer appears twice, once out and once in, and both legs must share a link ID so the ledger does not treat the pair as revenue and expense. The cleanest pattern is to match them in the deterministic layer by amount and date proximity across accounts, then route both legs to review together. If a matching fails, send both legs as uncategorized with low confidence rather than letting one leg post alone.

How often should I re-calibrate the confidence thresholds?

Re-calibrate whenever the chart of accounts changes, the model or provider changes, or the prompt changes materially, and otherwise on a fixed schedule using the most recent few thousand reviewed rows. Calibration drifts because merchant mixes drift. A quarterly check against a fresh labeled sample takes an afternoon and prevents the slow failure where a threshold that once meant "95% right" now means "80% right" because the business started buying from new kinds of vendors.

What is the single most important thing to get right?

The balance reconciliation gate. It is unglamorous, it never looks like the interesting part of the project, and it is the only control that catches a dropped row, a sign flip, or a truncated file before any of them reach the model or the ledger. An agent that reconciles and refuses to proceed on a mismatch is safe even when its categorization is mediocre. An agent with perfect categorization that silently loses a row is not.

Sources

Sources

  1. Reconcile bank accounts - Business Central
  2. What Is Ad Spend Reconciliation? Definition
  3. Structured output
  4. Introducing Structured Outputs for Batch and Agent ...
  5. Generate structured output from LLMs with Dottxt Outlines ...
  6. Contribution Margin vs. Gross Margin | Reformation Partners - Medium
  7. Chapter 15: Structured Output and Schema-Constrained ...
  8. What is the CAC payback period? - Stripe
  9. How I Calculate the CAC Payback Period - The SaaS CFO
  10. CAC Payback Period | Formula + Calculator - Wall Street Prep
  11. Understanding Gross Margin vs. Contribution Margin
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles