A borrower types: “My EMI went up this month. Why? And if I prepay ₹50,000 today, how much interest do I save?”
Paste the loan details into a capable language model and you will get a fluent, confident answer with a rupee figure in it. Often the figure is close. Sometimes it is off by a few thousand rupees, because the model assumed the wrong compounding, forgot the prepayment charge, or skipped the GST on that charge. In most products a near miss is fine. In lending, a plausible wrong number told to a borrower is a misstatement, and it is worse than saying nothing.
So in early November 2026 I built Mira, an AI customer-service agent for loan and credit-card servicing, around one rule: the model is not allowed to do the maths. Not some of it, not “simple” sums, none of it. It runs over text and live voice, for a fictional lender I called Kestrel Finance, and the code is public at github.com/guptasiddharth/mira-ai-servicing. It is my own reference build for what a regulated lender’s servicing agent should look like, not a product anyone runs in production.
If you read my post on Proof of Mandate, this is the same instinct pointed at a different problem. There I argued that you should not trust a model to refrain from a bad action; you should make the action un-signable. Here the bad action is a wrong number, and the fix has the same shape: take the number away from the model entirely.
Table of contents
Open Table of contents
- The rule: the model orchestrates, code computes
- Why this is not RAG
- The maths lives in boring functions
- The citation guard
- Actions the model cannot confirm by itself
- Five flaky backends behind one gateway
- Voice: get out of the audio path
- The numbers, and what they mean
- One platform, a second workflow
- What is simulated, and what is not
- What I took away from it
The rule: the model orchestrates, code computes
The first principle in the architecture doc is one sentence: the LLM orchestrates; code computes. The model decides what to look up and how to explain it. Balances, EMIs, savings, eligibility and charges all come from deterministic, unit-tested functions. It is enforced at three levels, because a prompt alone is a suggestion.
The prompt says it. Grounding rule two reads: “Never do arithmetic or financial projections yourself. Use the calculation tools and quote their numbers exactly.” Rule one says every account-specific number must come from the prefetched customer context or a tool result.
The tools make it unnecessary. There are 17 typed tools with zod schemas, and the ones that matter return finished numbers. explain_emi_change does not hand the model two schedules to diff. It returns the before and after instalment, the rate change, the reset reason and a mechanism string like “EMI recalculated on outstanding ₹X over the remaining N months; tenure unchanged.” The model narrates facts. It does not infer them.
The UI makes it visible. Every tool returns a typed view alongside the data the model sees, rendered as a card: an EMI comparison with a step chart, a prepayment comparison with a balance-over-time chart, a failed-payment timeline. The model writes a short takeaway; the card carries the numbers, and the card’s numbers came from code.
A side effect: the thinking level is set to minimal. When correctness comes from tools rather than reasoning, you can buy latency back by letting the model think less.
Why this is not RAG
The reflex for a support bot is retrieval: embed the policy PDFs, retrieve, generate. That works for “what is your foreclosure policy?” It cannot produce a correct EMI delta or interest saving, which need customer data, business rules, arithmetic and conversation state together. So the query engine is layered:
| Layer | What it does |
|---|---|
| Context prefetch | Profile, loans, recent payments and open requests load in parallel at session start, so “what’s my next EMI?” needs zero tool calls |
| Typed tools | Arguments validated before execution, so the model can only ask for things that exist |
| Deterministic maths | Reducing-balance EMI, schedules with floating-rate resets, payoff projection, prepayment, foreclosure quote with accrued interest and GST |
| Rules engine | Lock-in, prepayment caps, financial-year limits, charge slabs, bounce-reversal eligibility, each returning rule IDs |
| Policy text | A clause book generated from the same parameters the rules engine uses, cited by clause ID |
There is no vector database in the critical path. One would earn its place for long-tail FAQ content, as one more tool behind the same registry, not as the core.
The maths lives in boring functions
The amortization, prepayment and foreclosure modules plus the rules engine come to 383 lines. That is the point: pure functions you can read in one sitting and test exhaustively.
The prepayment simulator returns both standard options side by side: keep the EMI and close earlier, or keep the tenure and lower the EMI, each with months, total interest, interest saved, and net saving after charges and GST. When the borrower asks “what if I reduce the EMI instead?”, the model recalculates nothing. The answer was already in the result.
The rules engine is where the clause IDs come from. Every decision returns the rules it relied on:
if (f.emisPaid < P.lockInEmis) {
violations.push({
ruleId: "POL-PL-001",
message: `Lock-in: ${P.lockInEmis} EMIs must be paid before prepayment; ${f.emisPaid} paid so far.`,
});
}
if (amount > maxAllowedAmount) {
violations.push({
ruleId: "POL-PL-002",
message: `A single part-prepayment cannot exceed ${
P.maxPartPrepaymentPctOfOutstanding
}% of outstanding (${rupees(maxAllowedAmount)}).`,
});
}
Kestrel’s fictional policy has a six-EMI lock-in, a ₹10,000 minimum part-prepayment, a cap of 25% of the outstanding per prepayment, at most two per financial year, fixed-rate foreclosure charges of 4% up to 23 EMIs paid, 2% up to 35 and nothing after, and 18% GST on charges. In the demo, a fixed-rate customer asks to prepay ₹50,000; the engine blocks it under POL-PL-002 and returns the maximum they are allowed. A refusal that arrives with the allowed alternative, computed, is better service than a refusal.
The decision I am proudest of is the smallest. The policy book’s human-readable clauses are generated from the same parameter object the rules engine reads. Change the lock-in from six EMIs to nine and the rule and the cited clause change together. The citation cannot disagree with the decision, because they are the same number.
The citation guard
Telling a model to cite clause IDs creates a new failure mode: it will invent plausible ones. POL-PAY-04 looks exactly like a real clause. So citations are verified, not trusted.
The prompt says to cite only IDs that appear verbatim in a tool result. Then the runtime checks anyway. After each answer it extracts every POL-* reference and compares it against the clause IDs the tools actually returned, plus the conversation history. Anything left over increments a guard.unverified_citations metric and is logged with the turn ID. In the UI, a verified citation becomes a chip you can hover to read the clause; an unverified one carries an “unverified” label.
A unit test covers exactly this: given known IDs POL-PL-006 and POL-PL-008, an answer citing POL-PAY-04 must be caught. You do not stop the model hallucinating. You make the hallucination visible and countable.
Actions the model cannot confirm by itself
Answering questions is half the job. The other half is doing things: booking a part-prepayment, disputing a bounce charge, blocking a card. Anything that changes state is two-phase.
prepare() validates against policy and computes a preview with no side effects, and the UI renders a confirmation card. The customer confirms by pressing the button or by saying “yes, go ahead”, in which case the model calls confirm_action. The guard that matters: a model-initiated confirmation is rejected if it happens in the same turn the action was prepared. The model cannot prepare a dispute and confirm it on the customer’s behalf in one breath. Pending actions reach the next turn’s prompt from server state, never from the client.
commit() uses the action ID as the idempotency key, so confirming twice returns the original reference. Because a button click and a spoken “yes” can land on different serverless instances at once, a cross-instance claim in Redis lets only one of them commit.
Identity follows the same pattern. customerId comes only from the signed session and is injected into every tool; no tool takes it as an argument. A request for another customer’s loan returns FORBIDDEN, and a test checks it.
Five flaky backends behind one gateway
A lender’s data lives in systems never designed to be called by a chatbot. To keep the demo honest, Mira talks to five simulated systems of record (CRM, Loan Management, Payments, Policy and Service Ops), plus a sixth for cards, each with its own latency profile and failure rate.
Every call goes through one Integration Gateway: per-call timeouts, retries with exponential backoff and jitter, a circuit breaker per system that opens after five consecutive failures and probes half-open after a cooldown, a read-through cache invalidated on any write for that customer, and singleflight so identical concurrent reads collapse into one. Each system’s policy matches its SLOs: Payments gets a 2.2 second timeout, Policy gets 600 ms and a ten-minute cache.
When a system is down, the model gets SYSTEM_UNAVAILABLE and is told to say so plainly, share what it knows from other systems, and offer a ticket. Never invent data to fill the gap.
Voice: get out of the audio path
Voice uses Gemini Live with native audio, and the biggest latency decision was keeping my servers out of the audio entirely. The browser streams microphone audio straight to Gemini and plays the reply. My servers only see tool calls.
To do that without handing the browser an API key, the voice session endpoint builds the same instruction and tool declarations as text, then mints a single-use, 30-minute ephemeral token with the model, prompt and tools locked server-side. Tool calls relay back to my API and take exactly the same path as text. Barge-in flushes queued audio the moment the customer talks over it. Slow tools like Payments run non-blocking, so the model says “let me pull that up” and keeps talking. Text and voice share one transcript, so you can switch mid-conversation.
Measured from the last voiced microphone frame to the first audio played, which includes end-of-speech detection and is what the customer feels:
| Spoken turn | Path | Voice response |
|---|---|---|
| “What’s my next EMI?” | prefetched context | 1,341 ms |
| “Actually, check my last payment first.” | context, after a topic change | 1,412 ms |
| “Would paying ₹20,000 extra reduce my tenure?” | simulate_prepayment tool (233 ms) | 3,062 ms |
The tool took 233 ms. Most of the extra time is the model deciding to call it and then composing speech around the result.
The numbers, and what they mean
All of these were measured in November 2026 on a MacBook against local next dev, with Gemini on the free tier over the public internet.
Backend at scale. npm run bench drives 1,000 concurrent conversations, each a distinct customer running four turns through the real registry and gateway against fault-injecting simulators. No LLM is involved; this tests everything except the model.
| Mode | Result |
|---|---|
| Realistic (1 to 8% failure rates) | 6,000 tool calls in 5.1 s, 0 failed after retries; tool turn p50 409 ms, p95 1,385 ms |
| Degraded (2.5x latency, 5x failure rates) | 0.82% failed after retries; tool turn p50 649 ms, p95 3.0 s |
| Payments at 100% failure | Payments breaker opened; only the 1,000 payments-dependent calls failed, every other tool kept working |
The outage case is my favourite. Tool turn p95 actually fell to 1.2 s, because calls failed fast instead of hanging. In degraded mode the breakers correctly stayed closed: intermittent failure is a retry problem, not an outage.
Text chat. A scripted benchmark plays 4 journeys and 12 turns, from an EMI question through prepayment and booking, failed payments to a dispute, foreclosure options, and a duplicate card charge. All 12 turns completed correctly, including next-turn confirmations. Time to first token was 3.3 s at p50 with fresh quota.
That 3.3 s is honest and not great. Almost all of it is model time on a shared free tier; my own overhead is single-digit milliseconds once cached. A tool turn needs two model calls, one to plan and one to answer. On a paid tier with prompt caching I estimate full tool turns at 2 to 2.5 s, but that is an estimate, not a measurement. The free tier also shaped the runtime: it fails over across a chain of models, and since a later pass, a model that produces nothing within 9 seconds is abandoned for the next one, inside a 45 second turn budget.
The maths itself. npm test runs 34 unit tests covering finance functions, policy rules, gateway resilience, authorisation, two-phase actions and the citation guard, in about 2.5 seconds. That is the quiet benefit of keeping numbers in code: you can test them.
One platform, a second workflow
To find out whether this was a loan bot or a platform, I added credit-card servicing: deterministic duplicate-charge detection, card disputes with provisional credit, card block and replace. A workflow is a manifest of persona, rules, a tool allow-list, a context loader and account panels. The card workflow took roughly 125 lines of manifest, 175 lines of new tools and a 40-line adapter, and reused about 2,500 lines unchanged. The allow-list is enforced at execution, not just in the prompt.
What is simulated, and what is not
- The lender and its policy book are fictional. Kestrel Finance, its customers and its clauses are mine.
- All six systems of record are simulated, and in the deployment they share one Redis document with last-writer-wins on concurrent writes. Real systems of record would never accept that.
- The lending model is simplified: no broken-period interest, no moratoria, no penal interest on overdue EMIs.
- Login is a mock OTP, and there is no telephony; voice runs in the browser.
What is real: the maths, the rules engine, the citation guard, the gateway under injected faults, the two-phase actions, the locked voice tokens, and a deployment on Vercel with state in Upstash Redis, both pinned to Mumbai.
What I took away from it
- A model that never produces a number cannot produce a wrong one. Most of Mira’s safety is not a guardrail on output. It is the absence of any path by which a model-generated figure reaches the customer.
- Generate the explanation from the same source as the decision. Clause text built from the rules engine’s own parameters cannot drift.
- Verify citations; do not merely request them. Asking for sources gets you citation-shaped text. Checking each one gets you citations.
- Design for flaky backends first. The impressive demo is the one where Payments goes down and the assistant still helps, and that comes from a gateway, not a prompt.
- In voice, the architecture is the latency. Leaving the audio path did more than any prompt tweak.
The same rule, that no number a model produces should become something a person relies on, matters even more when the number is a price and the next step is a payment. That is what I want to write up next.