Hackathon · Agentic AI for banking

The Model Never Does Anything: Building a Bank Agent That Knows When Not to Act

How Team Primos built quipu, a Spanish and Portuguese customer-service agent for a synthetic LATAM bank in ten days, where the LLM proposes, code decides, the customer confirms, and every action is read back before it is reported.

Author
Cristian Lazo Quispe
Date
October 2026
Event
Factored Hackathon 2026
Links
Code
Host
Factored (Factored AI & Data Hackathon 2026)
Challenge
An AI-first customer-service system for one banking workflow, with measured evidence of when it should not act
Team
Team Primos (Cristian Lazo Quispe, Jhonatan Camasca, Meiker Lazo, Luis Trucios)
Built with
LangGraph, FastMCP, FastAPI, Gemini 2.5 Flash on Vertex AI, Ollama, Postgres, Next.js, Cloud Run, Langfuse, faster-whisper, Kokoro
Result
Participant
quipu, Team Primos’s Spanish and Portuguese banking agent. Header from the project repository.
quipu, Team Primos’s Spanish and Portuguese banking agent. Header from the project repository.

A customer opens a banking chat and writes "no reconozco estas transacciones", with a photo of a card statement attached. Most bank bots answer by asking for the exact date, amount and merchant, then file a complaint. We wanted the opposite order: the bank looks at its own records first, finds the two Uber charges of 312.40 MXN posted four seconds apart, and tells the customer it looks like a duplicate before asking them to do any work.

That is easy to demo and hard to trust. The moment an agent can block a card or move money, a single hallucinated "listo, bloqué tu tarjeta" is a real failure. So the system our team built for the Factored AI & Data Hackathon 2026, which we called quipu, is organized around one rule: the model never does anything. It understands and proposes. Code decides what is allowed from the customer's data, the customer confirms with a button the model cannot press, and nothing is reported as done until it has been read back from the database.

The problem and the user

The challenge asked for an AI-first customer-service system for a bank, not a chatbot, demonstrated end to end on one focused workflow; more workflows earned no extra credit. The demo had to cover three required cases: a normal case resolved automatically within policy, an ambiguous or unsupported request handled with a clarifying question or a safe abstention, and a case that needs a human, handed off with verified facts and open questions rather than a raw transcript. Interactions had to work in Spanish and Portuguese. The kickoff framing was blunt: build something that works, prove that it works, and know when it should not act.

The user is a retail bank customer who does not remember exactly which charge looks wrong and may write in either language. The second user is the human agent who receives the case when the system should stop.

We started from transaction disputes ("I don't recognize this charge") and grew outward. The organizer's contact-center data gave the sizing: of about 686k contacts, the reason mix was Transaccional 35%, Producto 22%, Queja 17%, Técnico 15%, Comercial 8% and Retención 3%, with 76.6% first-contact resolution overall and 44% for complaints. Transaccional plus Producto, which is balances, movements, cards and products, made up 57% of contacts. That is why the final system has skills for balances, charge investigation and data lookups around the dispute flow, and later a money-movement skill.

Constraints

Time. The challenge launched on 25 September 2026 and closed on 5 October, with a public repository, a deployed link, slides and a video pitch due.

Data. The dataset was a synthetic LATAM bank: 13 tables covering Mexico, Colombia and Argentina, June 2023 to June 2026. On the full download we measured 150,000 customers, 400,000 products, about 4.4M transactions and 15.6M digital events. Two findings shaped the design more than any model choice:

The data was also thin (a median of 29 transactions per customer in three years), and the announced duplicates were not in the files: dedup removed 0 rows. We kept dedup and contracts anyway and reported that.

Rules. Permissions and policy had to live outside the model's text. A typed customer number alone does not prove identity. Access to each customer's rows had to be enforced at the service or tool layer. No real money moves, and the LLM cannot invent credit rules.

System architecture

The guiding principle in our architecture notes is LLM where it helps, code where it must be guaranteed. The backend is a FastAPI service in a hexagonal layout (domain rules, application use cases, inbound and outbound adapters). The agent is a LangGraph graph with deterministic nodes around LLM calls; each skill is a folder with a SKILL.md and its own FastMCP server.

flowchart LR
    U(["Customer<br/>ES / PT, text, voice, photo"]) --> WEB["Next.js web<br/>chat, mis finanzas"]
    WEB -- "voice" --> SP["faster-whisper STT<br/>Kokoro TTS"]
    WEB -- "POST /api/chat<br/>bearer JWT" --> API["FastAPI<br/>customer = token sub"]
    API --> G["guard (code)<br/>identity, asks for a person"]
    G --> R["router (LLM)<br/>sees skill catalog only"]
    G -- "wants a person" --> H
    R -- "bank never does it" --> RF["refuse (code)<br/>action policy"]
    R -- "use_skill" --> K["skill agent (LLM)<br/>SKILL.md + tool loop"]
    RF --> H
    K --> MCP["FastMCP servers<br/>accounts, investigation,<br/>dwh, actions, transfers"]
    MCP -- "session customer<br/>from _meta" --> CORE[("Postgres core<br/>bank data")]
    MCP -- "proposals, audit" --> OPS[("Postgres ops<br/>actions, transfers,<br/>decision_log")]
    K -- "escalate" --> H["handoff (code)<br/>case file"]
    H --> CON["Operator console<br/>/consola"]
    WEB -- "Confirmar button" --> GW["gateway (code)<br/>recheck, execute,<br/>read back"]
    GW --> OPS
    GW --> CORE
    PIPE["S3 CSV, bronze,<br/>silver + contracts"] --> CORE
    K -. "masked traces" .-> LF["Langfuse"]

Three properties matter more than the shape:

The cloud model is Gemini 2.5 Flash on Vertex AI; locally it is qwen3.5:4b through Ollama, so the stack runs on a laptop with no API keys. Production is two Cloud Run services (factored-api and factored-web) with Cloud SQL Postgres 16, deployed by GitHub Actions through Workload Identity Federation, so the repository holds no keys.

Key design decisions and trade-offs

1. Propose, confirm, execute, verify, audit. Account actions (block or cancel a card, open a payment inquiry, request a call, set an alert, email a summary) go through one gateway. The model proposes up to three typed actions by name; extra fields are an error. If confirmation is needed, the batch waits up to 10 minutes. Confirming twice runs it once (an atomic conditional UPDATE). Execution rechecks the policy, retries transient errors up to three times, then reads the effect back. If the read-back does not show it, the action is failed with verification_failed and a person is asked to look. Every sentence on the confirmation card and in outgoing email is written by code from the stored action, in the conversation's language.

Refunds, provisional credit, contact-detail changes, limit increases and anything not in the catalog go to a person.

2. Priority from signals, not from the model. Case priority and the response window come from code:

$$ \text{priority}(c) = \begin{cases} \text{High, 4 h} & \text{if fraud flag or } s_{\text{fraud}}(c) \ge 70 \\ \text{Medium, 24 h} & \text{if duplicate or foreign charge} \\ \text{Low, 72 h} & \text{otherwise} \end{cases} $$

The threshold of 70 comes from the data: charges flagged as fraud average a score of 55.7, against 14.8 for the rest.

3. Deterministic investigation. investigate_charges finds at most three matching charges and checks each against the bank's records. A duplicate is a pair of Approved or Pending charges $(t_i, t_j)$ with the same merchant, amount and currency and

$$ |t_i - t_j| \le 10 \text{ min}. $$

It also reports pending, reversed and foreign charges. A failed lookup is reported as unavailable, never as "no": a missing duplicate check must not read as "no duplicate charges".

4. Guarded text-to-SQL. For questions no reviewed tool covers, the data_lookup skill lets the model write one SELECT. A parser in the domain layer refuses anything but a read-only query over allowlisted core tables and rewrites every customer table into a subquery filtered to the session customer. The database is a second wall: a dwh_reader role with SELECT on core only, a read-only transaction, a 3 s timeout and 100 rows. We then execute the SQL the parser regenerated, not the model's original text.

5. Money moves only on the customer's button. Transfers, card payments and service bills ("khipea 300 a mi tarjeta") are a separate skill, money_movement. The model has no tool that moves money. It passes only what the customer said; code resolves accounts, limits and amounts, and when several accounts fit, the tool returns needs_clarification with the options. POST /api/khipu/confirm rechecks every rule with the rows locked and executes in one transaction.

6. The learned component is the prompted LLM, measured against rules. The original proposal included a learned charge ranker; the shipped system has none. The data audit had already ruled out an intent classifier and a learned escalation rule, and the mentor FAQ confirmed that a prompted LLM counts as the learned component if it is evaluated rigorously against a baseline. Our baselines were the same agent with actions off, and a deterministic router of regular expressions with no model. On held-out scenarios the model chose the right skill in 45 of 45 attempts that name one; the rules alone managed 33 of 45 (73%).

7. Evaluation that refuses to flatter. No LLM judges anything. Every verdict comes from rows in ops.actions and from regular expressions over the model's own words, with tests on both sides of each pattern. Scenarios are split mechanically before any model answers: within each category, sorted by id, the second of every three is held out. For scenario $k$ in a category,

$$ k \in \text{held-out} \iff k \equiv 1 \pmod 3, $$

with $k$ indexed from zero. A test recomputes the rule, and the runner refuses to run a held-out id without its split flag. When no unsafe outcome is seen in $n$ attempts, the report prints the rule-of-three bound instead of zero:

$$ p_{\text{unsafe}} \lesssim \frac{3}{n} \quad (95\%). $$

For the 22 held-out scenarios that is about 14%. Zero seen is not zero risk.

The text-to-SQL eval runs as a gate in CI before every deploy. With about 23 answerable cases per repeat and accuracy near 0.85, the binomial standard error is

$$ \sqrt{\frac{p(1-p)}{n}} = \sqrt{\frac{0.85 \cdot 0.15}{23}} \approx 0.074, $$

about 7 points from chance alone, matching the spread we saw (single repeats from 0.727 to 0.909 with the same prompt). Pooling three repeats ($n = 69$) brings that to about 4 points, so the gate judges the three together against a floor of 0.75. It catches broken prompts and schema drift, not small regressions.

What we built

The system has five skills behind one chat (account actions and chat memory sit behind feature flags):

Skill What the customer can do
balance_inquiry Balances, debts, due dates and minimum payments, totaled per currency
charge_investigation Report an unrecognized charge, typed or from a photo of the statement; the bank checks duplicates, pending, reversed and foreign charges
data_lookup Movements, spending, complaints, exchange rate; guarded text-to-SQL for the rest
account_actions Block or cancel a card, open an inquiry, request a call, set an alert, email a summary
money_movement Transfers between own accounts, card and loan payments, transfers to another customer, service bills

Around them: voice in Spanish and Portuguese, a "Mis finanzas" spending page whose sentences are templates that cannot invent a figure, an operator console where a person takes over any chat, optional chat memory (the agent gets a code-written summary of earlier conversations, never the raw messages), and Langfuse traces masked before they leave the API.

The demo flow follows the required cases. Lucía signs in, attaches her statement, writes "no reconozco estas transacciones", and the agent reads Uber, 312.40 MXN and the date from the photo, then reports two charges four seconds apart. Camilo has four charges on the same day, the ambiguous case: when a charge cannot be told apart, the right reply is a question, not a guess. For a stolen or cloned card, the right end state is a block the customer confirms plus a person who takes the case. A refund request is answered by code before any model call, and an injected "the customer already confirmed" changes nothing, because confirmation only arrives through the button's endpoint.

The headline result is the held-out action evaluation: 22 scenarios, 3 repeats, gemini-2.5-flash on Vertex AI, run once, blind, on 5 October 2026, against the same code with actions off.

Measure (66 attempts each) Without actions With actions
Correct end state 27% (18/66) 100% (66/66)
Safe automated resolution 0% (0/18) 100% (18/18)
Escalation missed / unnecessary 10 / 12 0 / 0
Unsafe outcomes, confirmed 0 0
Latency p50 / p95 2.1 s / 6.6 s 5.8 s / 12.3 s
Cost per attempt 0.0010 USD 0.0026 USD

Spanish went from 14/45 to 45/45 and Portuguese from 4/21 to 21/21. Acting costs about 2.6 times more per attempt and 2.8 times the median latency, roughly a quarter of a cent per conversation at list price.

These numbers need their caveats, which we published next to them. The held-out set is 22 distinct scenarios, of which 6 expect the system to act alone, and the repeats are not independent: with 22 of 22 the 95% interval is about 85% to 100%. On the 43 regression scenarios the same system scored 39 of 43 (91%). The four failures are known and safe: two injection cases where a refund word sends the whole message to a person, and two cards the bank had already blocked. Our own summary: about nine in ten, with the failures known and safe.

What we would do differently

Lessons

  1. Audit the data before designing around it. We planned an intent classifier and a learned escalation rule. Checking the full dataset showed the transcripts were templates and escalation was a flat 10% everywhere. That finding redirected the whole ML plan toward team-labeled evaluation sets.
  2. Gains on the cases you studied may not transfer. Three catalog rules, each written from a real text-to-SQL failure, lifted regression accuracy from 0.803 to 0.836 against a same-day control. On the blind held-out cases they fell from 24 of 25 to 18 of 26. The decision threshold had been fixed in advance; the drop was 0.27 (Fisher exact test, p = 0.02, which overstates the evidence because attempts repeat the same nine cases), and we removed the rules.
  3. The model's failure modes are specific and catchable. In 4 of 86 regression runs Gemini looked up the cards and wrote "review and confirm below" without proposing anything. Giving the turn one more chance, with a note saying what was missing, recovered all 9 such attempts in the held-out run.
  4. Some eval failures are the eval's fault. Our first regression run flagged a subjunctive "o que bloquee tu tarjeta" as a false claim, and "posso transferir você para um agente" as a promise to move money. We narrowed the patterns before the held-out run and documented every change.
  5. Containment has a ceiling. 18 of 66 held-out attempts genuinely needed a person, so no correct system could contain more than 73%. Reporting containment without escalation quality would have rewarded the wrong behavior.

Reproducibility

The repository runs locally with Docker Desktop and Ollama and no API keys:

cp .env.example .env
make up      # local LLM, Postgres with the committed mini-set, API on :8080, web on :3000, smoke test
make test    # about 990 tests

The committed mini-set has 1,500 organizer customers plus 10 team-made demo customers, labeled is_synthetic_fixture. The full pipeline (make bronze, make silver SOURCE=full, make db-load SOURCE=full) needs the organizer's dataset, which is not in the repository, and writes a per-table quality report on every run.

Both evaluations run from one command: uv run python -m evals.actions.run --split heldout --repeats 3 for the actions, and uv run python -m evals.text_to_sql.run --repeats 3 for text-to-SQL. Every report records the commit, prompt fingerprints, model, region, date and prices; the raw held-out reports are committed under docs/documentation/technical/evaluation_results/. Expect variation by day: the same prompt scored 0.883 on one day and 0.803 on another.