How we built an agent that files your VAT return
Autofiling prepares and submits your German UStVA end to end. The hard part was never the model — it was making a stochastic agent safe enough to perform a legal filing. Here is the design.
- Category
- General
- Updated
- Author
- Stan Kharlap
Every quarter — or every month, if you are bigger — a German business has to send its VAT return, the Umsatzsteuervoranmeldung, to the tax office through ELSTER. It is due on a fixed date, it is unforgiving about arithmetic, and getting it wrong costs real money. Most accounting software helps you fill in the form. We wanted the software to just do the return — collect the numbers, check them, and file — while the human stays firmly in control of the one moment that matters.
That feature is called Autofiling, and the interesting part of building it had almost nothing to do with the language model. A model that can read receipts and add up VAT is table stakes. The engineering problem was the opposite of a demo: how do you let a stochastic agent perform an irreversible legal act on someone's behalf, and be able to prove, afterwards, that it did exactly the right thing?
Filing is a legal act, not a chat
The instinct with any agent feature is to wire a model to some tools and let it go. For a chatbot, fine. For a tax filing, that design is a liability:
- The submission is not idempotent. Sending a UStVA to ELSTER twice is not a duplicate row you can clean up later — it is two filings. A crash at the wrong millisecond cannot be allowed to re-file.
- "Probably correct" is not a bar. The output has to be inspectable and exact. Nobody signs a number they cannot see.
- It is deadline-bound. The agent cannot quietly stall. If something is missing, that has to surface as a concrete, fixable task — now, not on the due date.
So Autofiling is not a prompt with tools attached. It is a state machine with an agent working inside it, where every transition that touches the outside world is designed around the assumption that the process can crash at any point.
A pipeline, not a black box
An Autofiling run walks through explicit, persisted states — SCHEDULED → COLLECTING → RECONCILING → READY_FOR_APPROVAL → APPROVED → SUBMITTING → FILED — with two escape hatches, NEEDS_INPUT and FAILED, for when reality does not cooperate. The state lives in the database, not in the agent's head, so a run is always resumable and always auditable.
Readiness, not vibes
Before a return is worth previewing, the run enters RECONCILING — and this is where the agent earns its keep by being a good pessimist. Instead of confidently filing whatever it has, it looks for reasons the filing might be wrong and turns each one into a typed blocker.
Blockers are not free-text warnings. They are a closed set of machine-readable types — uncategorized_transactions, unverified_transactions, possible_duplicates, unclaimed_input_vat, vat_conflict, expired_bank_sync, missing_tax_settings, elster_validation_error, and a dozen more — each carrying a severity of info, warning, or blocking. A blocking one halts the run and pushes it to NEEDS_INPUT with a specific, resolvable task ("14 transactions in this period have no category") rather than a vague "something looks off." Informational ones ride along on the preview so the human sees them but is not stopped.
The point is that the agent is rewarded for surfacing problems, not for hiding them behind a confident answer. A return that shouldn't be filed yet never reaches the point where it could be.
You approve the payload, not the promise
When a run is clean, it renders a preview: the exact ELSTER payload that would be transmitted, rendered through the same report engine that does the real filing — just in test mode. That payload is then hashed, and the hash is stored on the run as preview_payload_hash.
Approval binds to that hash. When a human approves, we record approved_payload_hash alongside who approved and when. From that moment the two must agree. If anything upstream changes — a late receipt lands, a category is fixed, VAT settings move — the payload re-renders to a different hash, the stored approval is invalidated, and the run refuses to proceed on stale consent. You are never approving "the agent's plan." You are approving this exact set of numbers, and the system enforces that literally.
A submission that cannot double-file
This is the part I am most fond of, because it is boring in exactly the right way. The submission step is deliberately not wrapped in one big transaction. Here is the actual docstring from the runner:
def submit_approved_run(run: AutoFilingRun) -> AutoFilingRun:
"""Submit an approved run to ELSTER.
Deliberately NOT one big transaction: the ELSTER POST is a non-idempotent
external effect. SUBMITTING is committed *before* the external call, and
the result is committed in its own transaction afterwards — so a crash
can never roll the DB back to a state that would re-file the same UStVA.
"""
The flow, in order:
- In a transaction, re-check everything under a row lock — status, that the approved hash still matches the current payload, that the account is still entitled to file — then flip the run to
SUBMITTINGand commit. - Only then make the external ELSTER call, outside any transaction.
- Commit the outcome — filed, or a specific failure — in its own transaction.
Because SUBMITTING is durable before the network call, a process that dies mid-submission wakes up in a state that says "this was already in flight," and the recovery path checks whether ELSTER already accepted the report before it would ever send again. A transient provider hiccup backs the run off to APPROVED so a sweeper can retry it, up to a bounded number of attempts, without ever losing the approval. The one thing that can never happen is filing the same return twice. Everything else is recoverable.
Every step is on the record
Underneath Autofiling sits our agent layer, and its whole job is to make the agent legible. Every unit of model work — reading a document, drafting a fix, preparing the return — is recorded as an AgentRun: which workflow it belonged to, the provider and model that ran it, a hash of the input and of the output, the cost, the latency, and the exact PromptVersion that produced it. Model choice per workflow is itself policy — a ModelPolicy with cost, latency, and risk-level ceilings and a fallback model — not a constant buried in code.
That bookkeeping is not ceremony. It is what lets us answer, months later, "why did this run produce that number?" and get a real answer instead of a shrug. It is what makes cost and latency a dashboard instead of a surprise. And because every prompt is versioned and every real correction can be frozen into an evaluation case, it is the substrate that lets the agent measurably improve over time — with a person, never the model, deciding what ships.
The boring parts are the product
It would have been easy to build a flashier version of this: a chat box where you ask the agent to "file my VAT" and it does. It would also have been the wrong thing to ship, because the moment a filing is wrong, the demo magic becomes someone's problem with the tax office.
So the design leans the other way. The agent does the tedious, error-prone gathering and checking that no human enjoys. It refuses to guess when it should ask. It shows you the exact payload and makes you the one who signs it. And it treats the act of submission with the paranoia a legal filing deserves. That is the shape of every agent we build at Norman: automate the boring work completely, keep the human on the decision that carries the risk, and be able to prove every step in between.
Norman handles the operational finance work behind the scenes
From invoicing to bookkeeping, Norman keeps recurring finance work organized so you can stay on top of deadlines with less manual effort.