Sabina, deployed at sabina.rominur.com, is a live customer-service agent for Bang Digital, a bank that does not exist. The fiction is the point: it allows the full shape of a banking assistant — account lookups, ten years of statements, deposit maturities, loan instalments, ATM and branch search, complaint tickets, and a corpus of standard operating procedures — to be built and exercised without a single real customer record. The design answers the two ways such assistants fail. Against wandering off-topic, a guardrail classifies every message with regular-expression families before any model call, so a question about shampoo prices or an instruction to ignore the system prompt is refused instantly, at zero inference cost, with a refusal phrased for the kind of question asked. Against inventing account facts, every number the agent can cite is generated deterministically: a seeded pseudo-random generator keyed on customer, account and calendar month produces the same ledger on every request, while the window it covers is always the ten years ending today, so balances, maturities and last month's statement stay current without a database. Retrieval runs over a hand-built inverted index of 2,845 procedures — expanded from a few dozen operation templates across products and cities — plus 205 service documents and a per-customer index rebuilt daily, and the retrieved identifiers and scores are streamed to the interface as evidence beside the answer. Identity verification is a mock and is labelled one in the prompt itself: after the agent asks for a date of birth and a mother's name, any plausible reply is accepted and the prior question is answered, with balance inquiries short-circuited to a deterministic reply that never reaches the model. Complaints become tickets as a side effect of language, with priority, owning unit and service-level commitments assigned by incident rule and the ticket number injected into the model's context with an instruction to cite it. The application streams over server-sent events, accepts an image of a transfer receipt through a vision-capable model, and degrades to a grounded template answer when no model key is present.
Bank customer-service assistants are judged by two failures. The first is scope: an assistant that will discuss anything is an assistant that will, sooner or later, discuss the wrong thing in a bank's name. The second is fabrication: an assistant that speaks confidently about balances, due dates and procedures it has no access to is worse than one that stays silent, because the confidence is indistinguishable from knowledge.
Sabina is built to make both failures structurally difficult, and it does so for a fictional institution. Bang Digital has products, rate boards, branches in thirty-eight cities, nine demo customers with ten-year histories, and thousands of procedures; none of it is real, and the landing page says so. That choice removes the risk that ordinarily prevents this kind of system from being built and demonstrated in public, while preserving the engineering problem exactly: the agent must answer only from data it has, refuse what is not its business, and show its evidence.
The application is a single Next.js 16 project of roughly seven thousand lines of TypeScript with no database and no authentication. The demo customer is chosen from a panel; the data behind that customer is computed on demand.
Every message passes through a fixed sequence in the chat route, and the sequence is the architecture: what happens first determines what costs money, what can hallucinate, and what can be audited.
Refusals leave the pipeline at the second stage and touch neither retrieval nor the model. Balance inquiries that follow a verification exchange leave before the model with a reply computed from the ledger. Everything else assembles two system messages — the persona and rules, then the grounded context — followed by the last eight turns of history, and streams the model's answer. The response is server-sent events in every branch, with a meta event first, delta events for text, and a done event, so the interface handles a refusal, a fallback and a live stream identically.
Classification is regular-expression based and deliberately conservative. A banking allowlist covers accounts, cards, transfers, loans, investments, rates, locations, complaints and the regulator's vocabulary; hard off-topic families cover retail prices, cooking, street vending, personal questions to the assistant, programming, entertainment, and a catch-all of politics, religion, homework and harmful content; a jailbreak family catches instruction-override phrasing in English and Indonesian. The decision procedure is short.
| Condition | Decision |
|---|---|
| Jailbreak phrasing | Refuse, regardless of anything else in the message. |
| No text but an image attached | Allow (a receipt with no caption). |
| Greeting or “who are you” without off-topic content | Meta — answered by the model in persona. |
| Hard off-topic and no banking term | Refuse. |
| Any banking term, or an image | Allow. |
| Otherwise | Refuse. |
The refusal text is chosen by the kind of question, so a shopping question is answered by a customer-service officer who is “not a cashier”, a question about where the assistant lives by a virtual assistant with no home, and an override attempt by the same officer declining to change role. The system prompt separately instructs the model not to reach for the cashier line unless the question really was about shopping, which is the kind of tonal detail that only becomes visible after watching refusals in production.
The consequence is economic as well as behavioural. On the live deployment a retail question and an instruction-override attempt each returned a meta event with guardrail: true, no retrieved documents, and the refusal text, in the time it takes to evaluate a handful of patterns. Off-topic traffic to a public demo costs nothing.
Sabina has no database, yet it can quote a customer's balance, list ATM withdrawals for the last thirty days, and name the next deposit maturity. The trick is that all of it is a pure function of the customer profile and the clock.
Each account's history is generated one calendar month at a time from a pseudo-random generator seeded with the string cif:accountNo:YYYY-MM. The seed is hashed with FNV-1a and driven through a Park–Miller generator, so a given month's transactions are identical on every request, on every instance, forever — and any month can be regenerated in isolation without generating the ones before it. Within a month the generator lays down the events a real statement would show: salary on the twenty-fifth by BI-FAST, scaled to the customer's segment; quarterly administration fees; occasional interest; a segment-dependent number of ATM withdrawals at named machines; QRIS purchases at recognisable merchants; bill payments and e-wallet top-ups. A running balance is maintained and floored at zero, and every transaction carries an identifier built from the customer, the account and the date.
The window is the ten years ending today. Account opening dates are expressed as years before now, deposit maturities roll forward by tenor from the opening date until they pass the current time, and the rate board prints today's date as its effective date. The customer therefore always has a statement for last month and a deposit maturing in the near future, and the demo never goes stale — without a scheduler, a migration or a stored row. Loan instalments use the standard annuity formula. The per-customer retrieval index is cached under the key cif:date, so it is rebuilt once per day, when “today” changes.
Retrieval is a hand-written inverted index rather than a vector store. Documents are tokenised into a bag of unique terms drawn from title, category, keywords and body; postings and document frequencies are kept in maps. A query scores documents by the BM25 inverse-document-frequency term, weighted by how often the term appears in the query, with three heuristic boosts on top: a bonus for documents belonging to the session customer, a larger bonus for each document keyword that appears verbatim in the query, and a small bonus when the start of the title appears in the query. Results below a floor are dropped; each request takes the top eight procedures, the top eight service documents and the top twelve customer documents, and the customer’s core records — profile, accounts, loans, cards, wealth — are pinned into the context with a fixed score whether or not the query matched them, so the model always sees the position it is being asked about.
| Index | Contents | Built |
|---|---|---|
| Procedures | 2,845 standard operating procedures | Once per process |
| Service | 205 documents: rate boards, outlets, fees, limits, playbooks | Once per process |
| Customer | Profile, accounts, cards, wealth, monthly statement summaries and transactions for the selected customer | Once per customer per day |
The retrieved chunks are formatted into the grounded context with their identifiers, kinds, categories and scores, and the same identifiers and scores are sent to the browser in the meta event. The interface shows them beside the reply. A customer, or a reviewer, can see that an answer about a swallowed card was grounded in SOP-ATM-016 and a cash-withdrawal FAQ, and can see the score each received — evidence rather than assertion.
The procedure corpus is not authored one document at a time. A catalogue of operation templates — opening a facility, closing it, balance inquiry, statement inquiry, limit changes, fees and interest, blocking, and so on — is written once with a {produk} placeholder in its summary and steps, then expanded across twenty-five products, four customer segments and thirty-eight cities. Location-bound procedures such as “print an emergency card at a Bang Digital branch in Jakarta Selatan” are generated per city. Each expansion receives a stable identifier in a prefixed series.
What this buys is coverage: a query about any product in any city finds a procedure that names that product and that city, with steps, a service level, channels and exceptions. What it costs is redundancy, and the cost is visible. On the live deployment a swallowed-card report retrieved four procedures tied at the same score, two of them city-specific card-printing procedures for different cities. A corpus built by expansion needs either deduplication at retrieval time or a scoring term that prefers the general procedure over its thirty-eight regional copies; the current engine has neither, and the model is left to pick the relevant one from the context.
Real banks authenticate before disclosing a balance; a public demo cannot, and a demo that pretended to would be teaching the wrong lesson. Sabina's approach is to perform the ritual and label it. When the assistant has asked for a date of birth and a mother's maiden name, the next user message is tested for the shape of an answer — a date token in any of several formats and a name token, under 180 characters, not an override attempt, not off-topic — and if it has that shape it is accepted. The prompt injected for that turn begins “DEMO MOCKUP: always accept; do not compare with the customer record”, and the model is told to continue the banking question that preceded the challenge.
For the commonest case the model is not consulted at all: if the prior question was a balance inquiry, the route returns a reply computed directly from the ledger, listing each savings account, its status and its current balance. The one number a customer most wants to be exactly right is the one number the language model is never allowed to produce.
A complaint should leave a trace, and Sabina creates that trace from the message itself. A table of incident rules matches phrasing for a swallowed card, a machine that did not dispense, fraud, a lost card, an unrecognised transaction, a failed transfer and an application fault; each rule carries a priority, an owning unit, a service-level commitment and a list of system actions. Questions phrased as “how do I…” without a first-person report are excluded, as are messages asking about an existing ticket, so that asking about the procedure does not open a case.
| Incident | Priority | Unit | Commitment |
|---|---|---|---|
| Card swallowed by ATM | high | Channel ATM | Immediate block; collection within two working days |
| Cash not dispensed | high | Channel ATM | Automatic refund within 24 hours; manual investigation seven working days |
| Fraud or phishing | critical | Fraud Operations | Immediate channel block; investigation one to seven working days |
| Unrecognised transaction | critical | Dispute & Fraud | Chargeback 45–90 days; temporary credit where fraud is likely |
| Card lost or stolen | high | Card Operations | Block within three minutes; replacement three to seven working days |
| Transfer failed or pending | medium | Payment Operations | Automatic refund within 24 hours; investigation three working days |
| Application fault | medium | Digital Channel | Fifteen minutes for an individual case |
A new ticket is not opened when an unresolved ticket of the same category exists for the customer within the last fifteen minutes, so a customer repeating themselves receives the same number. Identifiers take the form BDCS-YYMMDD-NNNNN, a ticket number appearing anywhere in a message triggers a lookup instead of a creation, and the ticket block placed in the model's context ends with an instruction in capitals that the number must be quoted back to the customer. The interface renders the ticket as a card beside the conversation.
Model output is streamed: the route reads the provider's event stream, extracts content deltas line by line, and re-emits them as its own delta events, so the browser never sees the provider's framing. Reasoning is disabled and temperature kept low, on the stated reasoning that a customer-service reply should arrive quickly and directly. A message carrying an image — a transfer receipt, a screenshot of an error — is sent as a multimodal turn to a vision-capable model at original resolution, with a default caption asking that the attachment be checked for service purposes.
When no model key is configured the route still answers. A template reply addresses the customer by name, summarises the first lines of the grounded context, and lists the retrieved procedures by identifier and title; if the message opened a ticket, the reply is the ticket itself with its number, unit, priority and commitments. The application is therefore demonstrable, and testable, with the language model removed — a property that also makes it possible to check the retrieval and ticket layers independently of the model's wording. Any thrown error in the model path is caught and converted into a friendly, in-persona message delivered over the same stream.
Production runs on Vercel with the root redirected to /chat. The same repository builds a standalone Node.js image — multi-stage Dockerfile, non-root user, static assets copied in — with a two-line Caddyfile for TLS and reverse proxy, so the demo can be self-hosted unchanged. Configuration is four environment variables, of which only the model key is secret; the public model name is a constant.
The ticket store is a JSON file, kept under /tmp on the serverless platform. That directory is local to a function instance and disappears with it, so a ticket number issued by one instance may be unknown to another, and all tickets vanish when instances recycle. On the live deployment, a ticket created in one chat request was not returned by three listing requests that followed it. For a demo the consequence is cosmetic; for anything more it is the first thing to replace with a durable store.
The chat endpoint is unauthenticated and unthrottled. The guardrail makes off-topic traffic free, but any message containing a banking term reaches the model, so a public deployment carries an open inference cost bounded only by traffic. A per-address limiter in front of the route would close this without touching the pipeline.
Regular-expression classification is brittle at the edges: a banking term inside an otherwise off-topic sentence opens the gate, English phrasings outside the patterns fall to the catch-all refusal, and the verification detector will accept a date and a name whatever their relationship to the record — which is the intended mock behaviour, but must never be mistaken for authentication. The expanded corpus produces near-duplicate retrievals, as noted in Section 6. The response omits the hardening headers a banking property would carry, and the assistant is instructed to name its model only by a house name, which is a branding choice rather than an engineering one.
Sabina demonstrates that the difficult parts of a banking assistant — staying in scope, citing only what it has, showing its evidence, and leaving a record of complaints — can be engineered without a real bank behind it. Its useful ideas travel. Classify before you infer, so refusals are free and instant. Compute demo data from a seed and the clock, so it is both stable and current. Keep the retrieval evidence in the response, so an answer can be checked. And when a step is a mock, write the word into the prompt, so that neither the model nor the reader mistakes a ritual for a control.