Our agent has 102 tools. The engineering is in what it never sees
Every instinct in agent design right now is additive: more tools, more instructions, more memory. Norman exposes 102 tools and 19 workflow playbooks, and almost every architectural decision we made was a subtraction. Here is what we take away from the model, and why the agent is better at its job for never knowing what step it is on.
- Category
- General
- Updated
- Author
- Stan Kharlap
Connect a client to Norman's MCP server today and it will hand you 102 tools across fourteen modules. Create an invoice, categorize a transaction, walk a founder through incorporating a GmbH, prefill a Fragebogen zur steuerlichen Erfassung, pull a VAT report, pay a bill. On paper that number is the pitch: look how much the agent can do.
In practice that number is the problem. Everything in agent design right now pulls in the additive direction: give the model more tools, more instructions, more memory, more autonomy, and trust that a better model absorbs the mess. We went the other way. Almost every design decision in Norman's agent layer is a subtraction, and the single most useful rule we landed on is that the agent is never allowed to know what step it is on.
The sketches below are simplified to the shape of the thing rather than lifted from our source, but the shapes are real.
Tool count is not capability. It is a budget spent twice
A tool costs you in two currencies, and only one of them is obvious.
The obvious one is tokens. Every tool definition ships in every request, so a hundred tools is a fixed tax on every single turn of every conversation, whether the user is founding a company or asking what their VAT deadline is. The less obvious one is attention. Past somewhere around fifty tools, models get measurably worse at picking the right one and at following the instructions that came with it. You do not notice this as an error. You notice it as an agent that starts doing approximately the right thing.
So the tool list is not a constant in our server. It is assembled per user, at startup, from what that user could plausibly need:
register(core_tools) # invoices, transactions, taxes
if company.is_incorporated:
register(chart_of_accounts_tools) # meaningless to a freelancer
if user.role == "tax_advisor":
register(multi_client_review_tools) # meaningless to a business owner
A freelancer never sees the chart-of-accounts tools, because a freelancer does not have a chart of accounts. A business owner never sees the tax advisor's review tools. Our own comments record what each of those skips is worth, and for a typical user the total lands somewhere in the region of five to eight thousand tokens shaved off every request.
Unglamorous plumbing, and the cheapest reliability win in the whole system: the fastest way to stop a model from calling the wrong tool is to not send it that tool.
Playbooks belong in an index, not in the prompt
Beyond the tools, we maintain 19 workflow playbooks as Markdown files in the repo: monthly reconciliation, overdue reminders, finding missing receipts, incorporating a company, DATEV preparation. Real procedural knowledge, the kind that took a domain expert to write.
The naive move is to concatenate all of it into the system prompt. That is on the order of ten thousand tokens of instructions on every request, most of them irrelevant to whatever the user just asked, all of them competing for the model's attention with the thing that actually matters.
So we ship the table of contents and let the agent ask for the chapter:
# In the system prompt: names and triggers only, a few hundred tokens.
for workflow in workflows:
prompt += f"- {workflow.name}: {workflow.trigger}\n"
@tool
def get_workflow_details(name: str) -> str:
"""Step-by-step body for ONE workflow. Call before executing it."""
return workflows[name].body
The bodies together run to something like ten thousand tokens; exactly one of them is ever in context. Roughly a twentyfold reduction against inlining everything, and the model reads the full procedure at the moment it is about to follow it, which is also the moment it pays the most attention to it.
The same instinct governs the order of the prompt itself. Our model provider, like most of them now, caches a sufficiently long shared prefix across requests and bills it at a discount, so prompt layout stopped being an authoring question and became a cost question:
prompt = STATIC_PREFIX # identical for every user, stays cached
prompt += workflow_index() # identical for every user
prompt += company_facts(company) # per-company, therefore always last
Put one user's company name near the top and you have invalidated the shared prefix for every other user on the platform.
The rule that matters: never track progress yourself
Here is the one I would defend hardest.
Three of our flows are genuinely long: incorporating a GmbH or UG (18 tools), the freelancer Fragebogen zur steuerlichen Erfassung (12 tools), and the Gewerbeanmeldung (10 tools). Each is a multi-session, five-section data collection where a founder wanders off mid-flow, comes back tomorrow, changes their mind about the legal form, adds a third shareholder, and expects the agent to know exactly where things stand.
The tempting design is to let the model own that. Give it memory, let it summarize progress, trust it to remember that capital is done but the notary section is not. We do the opposite, and the playbook says so in one line:
The backend is the source of truth. Navigate by
sections.missing; never track progress yourself.
Every tool response, whatever it was called for, carries the whole state of the flow back with it:
{
"status": "data_collection",
"sections": {
"company": { "complete": true, "missing": [] },
"shareholders": { "complete": false, "missing": ["managingDirector"] },
"capital": { "complete": false, "missing": ["nominalSumMismatch"] }
}
}
Progress is a pure function of the database, recomputed on every single call. Nothing about it lives in the conversation.
The detail that shows why this matters is that last marker. The declared share capital and the sum of the shareholders' individual nominal amounts have to match exactly, which is not a per-field validation but a relationship between several separate pieces of data:
def missing_in_capital(record) -> list[str]:
missing = [f for f in REQUIRED_FIELDS if not getattr(record, f)]
# cross-field invariant: the parts must sum to the declared whole
if sum(p.nominal for p in record.parts) != record.declared_total:
missing.append("nominalSumMismatch")
return missing
A founder who lowers the share capital an hour after the shares were already split gets that marker back on the very next call, and the agent asks about it, because it is sitting right there in the response it just received. No amount of conversational memory would have caught that reliably.
The payoff is that every hard question about long-running agents stops being an agent question. Resumability: the flow resumes because state was never in the conversation. Concurrency: a user editing the same record in the web app cannot desync the agent, because there is nothing in the agent to desync. Context loss: truncating the history costs you nothing structural, because the next tool response reconstructs the entire picture. Hallucinated progress becomes structurally impossible, because the model is never the thing being asked.
We hold this even where two sources of truth genuinely exist. The formation roadmap tracks seven steps after the notary appointment, and each can be completed either by the founder marking it or by our ops team advancing the case. Both are legitimate, and neither may overwrite the other:
# Done if the founder marked it, OR if operations already passed the milestone.
# Founder self-marks never write to the operational status, which keeps the
# milestone emails correctly timed and makes undo a clean toggle.
done = bool(step.marked_at) or ops_status_reached(record, step.milestone)
Reconciling that inside a prompt would be hopeless. As a computed read over two independent columns it is about six lines.
The most important tool is the one we did not ship
The corporate registration flow has nine tools for collecting and validating data, and then it stops. Not because we ran out of time:
get_registration, get_choices, create_registration,
update_company, update_details, update_people,
update_financials, update_vat_and_bank,
get_submission_link # returns a URL + readyToSubmit
# Deliberately absent: submit()
The questionnaire is e-filed to the Finanzamt through ELSTER, and that final, binding step happens only in the Norman app, where the user sees a rendered preview of every answer and presses Submit themselves. The agent's terminal capability is to hand the user a door.
This is the same line we drew in Autofiling: anything that files with the tax office, moves money, or sends a letter is approval-gated, and the gate is not a confirmation prompt the model could talk its way through. It is the absence of a tool. There is no phrasing, no jailbreak, no confused multi-turn state that lets the agent file a binding tax document, because that verb does not exist in its vocabulary. Capability you never grant needs no guardrail.
I would rather explain to a user why the agent handed them a link than explain to a founder why it e-filed the wrong legal form to the Finanzamt.
What this looks like in production
Over the last two weeks our agent layer ran north of a hundred thousand traced runs, and the shape is worth sitting with: the overwhelming majority are categorization and OCR, high-volume narrow jobs where a model does one scoped thing inside deterministic code. The conversational agent, the one holding all 102 tools, accounts for a few hundred. The widest tool surface carries the thinnest traffic.
That inversion is not a failure of the chat agent. It is the architecture working as intended. Conversation is where ambiguity lives, so it gets the most capability and the tightest constraints; the high-volume paths are narrow by construction and need almost none. Being AI-first does not mean routing everything through a chat box. It means being precise about which decisions genuinely need a model, and building the boring machinery that makes those decisions safe.
We will keep adding tools; there is a long list of things Norman can do that the agent cannot reach yet. But the ratio we actually watch is not tools shipped. It is how much the model has to hold in its head to use them, and that number we work to keep flat.
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.