The hardest sentence in consumer banking AI is not “What’s my balance?”
It is: “Whenever my salary hits, convert 20% to USDC, send rent to this account, and park the rest in savings — unless my balance would drop under ₹50,000.”
That is not a FAQ answer. That is a durable program the customer just authored in natural language. If you stub it with a brittle chatbot + cron, you will wake up to double transfers on one delayed salary webhook. If you wire it correctly — conversation → structured rule → Temporal workflow → trigger-driven execution — you get a product users trust with payday.
We built that pattern into a banking-style app: crypto↔fiat conversion, money movements, and salary-day rules set entirely by conversation. The architectural lesson was blunt: do not start with twelve specialists. Start with one clear agent — then split into Query vs Workflow the moment you feel the urge to “also book a transfer” inside a balance answer.
Measured product outcomes live in the conversational banking rules case study. This post is the wiring diagram.
What the product actually did
Users talked to the app. The AI did one of two things:
- Answered — balances, recent txs, FX quote, “can I afford rent after conversion?”
- Programmed — created or edited a durable rule that Temporal owned forever until the user paused it.
Triggers we supported first:
| Trigger | Example utterance | Workflow behavior |
|---|---|---|
| Salary credit detected | “When salary comes on the 1st…” | Wait for bank credit webhook / ledger event → run rule |
| Balance threshold | “If USDT > $2,000, convert half to INR” | Signal on balance poll / wallet update |
| Calendar / payday hint | “Every month after salary…” | Schedule + confirm against actual credit |
| Manual run | “Do the rent split now” | One-shot execution of the same activities |
Money paths:
- Fiat ledger moves (internal transfer, payee payout)
- Crypto → fiat (and reverse) via conversion rails
- Multi-step recipes (convert → transfer → notify)
Same rails instincts as enterprise settlement — just sitting under a chat UX.
The agent split that saved the build
Trap: one unified mega-agent
One agent with tools for getBalance, createRule, executeTransfer, convertCrypto feels elegant for a week. Then:
- A simple balance question “helpfully” offers to move money
- Tool lists bloat; model picks
createRulewhen the user asked “what if” - Latency and token cost explode on every read
What worked: Query agent vs Workflow agent
| Query agent | Workflow agent | |
|---|---|---|
| Job | Read-only answers + soft simulations | Author and mutate durable rules |
| Tools | balances, txs, quotes, policy FAQs | create/update/pause rule, attach trigger |
| Side effects | None (or draft-only preview) | Writes Temporal schedules / workflows |
| Confirmation | Optional | Always — show rule card before commit |
| Model temp | Lower, citation-friendly | Structured output + Zod |
Where the intent router sits — and when you do not need it
This is the confusion: money execution is deterministic. Chat ingress is not.
| Path | Intent router? | Why |
|---|---|---|
Salary webhook / threshold / Schedule → RuleRunWorkflow | No | Event already names the ruleId. Dispatcher is pure code. No LLM. |
Platform MCP (create_rule, get_balances, …) | No | Caller already chose the tool. AuthZ + DSL validator only. |
| In-app buttons (“Create salary rule”, “Check balance”) | No | UI selected the agent / tool. |
| Free-text chat (“when salary reaches…”) | Yes — thin ingress only | Natural language is ambiguous until structured. |
So: Temporal runs are deterministic DSL interpreters. The intent router is only an NL front-door into Query vs Workflow agents. If your product is mostly forms + MCP + cards, you can skip an LLM router entirely and keep agents only for drafting. If chat is the product, keep the router — but keep it dumb and fail-safe:
- Prefer UI mode switch or structured chips when possible.
- Else a small classifier (or even keyword + regex + LLM fallback) →
query|workflow|clarify. - On low confidence → clarify, never guess
create_rule. - On mixed intents (“what’s my balance and also set a rule”) → clarify or run Query first, then offer Workflow card.
- Never put the router inside Temporal Activities that move money.
After confirm, the LLM is out of the loop until the user chats again.
That is the same boundary discipline as our banking ops layer and exchange desk copilots — reads vs writes — just productized for consumers. Start unified only for a vertical slice; split Query vs Workflow the day staging shows an accidental side effect.
Conversation → Temporal: the only pipeline that stays honest
┌─ free-text chat ─┐
│ │
▼ │
Intent router │ (NL only — optional if UI/MCP)
query | workflow | clarify
│ │
▼ ▼
Query Agent Workflow Agent ──► RuleDraft (Zod) ──► confirm card
│ │
│ ▼ user confirms
│ rules DSL (DB) + Temporal register
│ │
MCP / buttons ───┴────────── (no router) ───────────┘
│
salary webhook / threshold ── deterministic dispatcher ──► RuleRunWorkflow
│
Activities (convert, transfer, notify)
│
Idempotent ledger + receipt
RuleDraft (conceptually) includes:
- Trigger type + parameters
- Ordered steps (convert / transfer / notify)
- Guards (“abort if post-balance < X”)
- Limits (max notional / day)
- Idempotency namespace (userId + ruleId)
Temporal owns:
- Waiting on salary Signals (or Schedules that verify credit landed)
- Retries on exchange / bank API failure without double-pay
- Pause / resume when the user says “stop the salary split”
- Full history for “why did you send rent twice?” support tickets
The LLM never “remembers” to run next month. The workflow does.
What each rule actually is (DSL + Temporal — not “chat memory”)
Yes: every confirmed rule becomes a durable program, not a prompt the model might forget.
More precisely, each rule is two things:
- A typed rule DSL (document) — Zod / JSON schema: trigger, steps, guards, caps,
ruleId. This is the source of truth stored in your DB (and optionally versioned). Chat and platform MCP only write this after the confirm card. - Temporal durable execution — that DSL is run by Temporal. Do not leave a single Workflow waiting open for three years without a strategy for history limits (below).
Recommended runtime shape (stays under Temporal limits)
| Piece | Role |
|---|---|
rules table (DSL) | Canonical definition; pause/edit/version |
| Trigger path | Salary webhook / threshold / Schedule → dispatcher |
RuleRunWorkflow (child, short-lived) | One Temporal run per firing: convert → transfer → notify, then complete |
Optional RuleSupervisorWorkflow | Long-lived waiter / Schedule owner for that ruleId — must Continue-As-New |
Avoid: one mega-Workflow that, every payday forever, appends convert/transfer events until history explodes. Prefer short runs per trigger + a thin supervisor/Schedule.
Platform MCP create_rule writes the same DSL and registers the same Temporal wiring — Product B does not invent a second execution engine.
Temporal limits you must design for
Temporal is durable, not infinite-history. Bake these into the banking product:
| Limit / concern | Practical default (self-hosted & common Cloud guidance) | What we do in this product |
|---|---|---|
| Event history count | Hard fail around ~51,200 events (warn earlier, often ~10k) | One RuleRunWorkflow per salary/trigger event; complete after steps |
| Event history size | Hard fail around ~50 MB (warn ~10 MB) | Never put statements/PDF/base64 in Activity results — store blob refs, return ids |
| Continue-As-New | Required for long-lived waiters | Supervisors call Continue-As-New when continueAsNewSuggested (or on a cadence, e.g. monthly) |
| Pending activities / children / signals | Defaults on the order of thousands pending per execution | Don’t pile unbounded Signals; drain / Continue-As-New; use child runs |
| Activity payloads | Inputs/outputs are recorded in history | Keep args tiny (ruleId, eventId, amounts); fetch details inside the Activity |
| Workflow Id | Stable id per rule, e.g. rule:{userId}:{ruleId} | Supervisor uses fixed Workflow Id; each run gets a new Run Id |
| Idempotency | Your problem, not Temporal’s alone | Activity id / client key = ruleId:triggerEventId:stepIndex |
| Retention | Namespace retention eventually closed histories | Support “why did rent send?” from ledger + short Temporal retention, not endless UI history |
| Worker concurrency / task queue | Cluster/CPU bound | Separate queues: rules-exec (money) vs rules-query (reads) so chat load can’t starve payouts |
| Schedules | Fine for calendar hints; still verify real salary credit | Schedule may wake a check; execution still keys off credit event id |
Quota planning (product, not Temporal hard caps): decide max active rules per user (e.g. 20), max steps per rule (e.g. 8), max notional/day — enforce in the DSL validator before create_rule, not only in the LLM prompt.
If a rule must “live forever,” that means forever as a definition + forever via Continue-As-New / new runs — not forever as one unrekeyed Event History.
Triggers that fire — and how you keep them from firing twice
- Banking core / BaaS webhook:
salary_creditwith amount + account → Signal to waitingSalaryRuleWorkflow. - Ledger projection: if webhook is late, a short poll Activity confirms credit then Signals.
- Crypto wallet indexer: balance crossed threshold → Signal
ThresholdRuleWorkflow. - Idempotency: Activity client ids =
ruleId:triggerEventId:stepIndex. Same salary UTR cannot execute the recipe twice.
If the user’s bank sends two webhooks for one salary, Temporal + idempotency keys make the second a no-op. Chatbots with setInterval do not.
UX that earns the right to move money
Every Workflow agent turn that would persist a rule returns a human-readable card before commit:
When salary credit ≥ ₹X on account ···1234
- Convert 20% to USDC (cap $Y/day)
- Transfer ₹Z to “Rent” payee
- Move remainder to Savings
Abort if spendable fiat would fall under ₹50,000
[Confirm] [Edit] [Cancel]
No confirm → no Temporal write. Same maker-checker instinct as institutional banking, compressed into consumer UX.
Query agent can simulate the card without creating the workflow — that alone cut support tickets in half during testing.
Stack notes (practical)
- Vercel AI SDK (or equivalent) for both agents — Zod tool schemas, streaming chat
- Temporal for RuleWorkflow + execution Activities
- MCP in both directions (see next section)
- Router kept dumb on purpose
- Optional Judge later: sample failed rules, rewrite clarification prompts — never silent limit changes
For desks and bank ops we deepen the Control plane; for this consumer product the confirm card is the control plane.
Two faces of MCP: adapters and your product as a platform
Most write-ups (including early cuts of ours) only describe southbound MCP — the agent calling tools into the ledger, conversion rail, and payee directory.
That is necessary. It is not enough if you plan a second product.
Northbound / platform MCP is the move: the banking app itself becomes an MCP server that other products (and other agents) can call with the same policy, Temporal durability, and idempotency you already trust.
Future Wealth app / Cards app / HR payroll agent
│
│ MCP client (scoped token)
▼
┌─────────────────────────────────────┐
│ Banking product — Platform MCP │
│ get_balances · quote_convert │
│ create_rule · pause_rule │
│ preview_salary_recipe │
└──────────────┬──────────────────────┘
│ same Activities / Temporal
▼
Ledger · Crypto↔fiat · Payees · Webhooks
Example platform tools you expose (not invent later under a one-off REST mess):
| Platform MCP tool | Who calls it | What it must enforce |
|---|---|---|
get_balances / list_payees | Wealth AI, support bot | Read scopes only |
quote_convert | Tax / FX helper product | No side effects |
preview_salary_recipe | Any UX that drafts rules | Dry-run via Query path |
create_rule / pause_rule | New “Goals” product, partner agents | Same confirm / limit / Temporal path as in-app chat |
run_rule_now | Ops console | AuthZ + idempotency + audit |
Why this matters commercially: when you ship Product #2 (investing, cards, expense, B2B payroll), it does not re-implement salary webhooks and conversion. It connects as an MCP client to the banking product. One Temporal spine. One risk policy. Multiple UIs and agents.
Design rules we deliberately bake in:
- Same Activity code for in-app Workflow agent and external MCP callers — never a “fast path” that skips confirms for partners.
- Scoped tokens per product (Wealth can
preview+create_rulefor its users; not global admin). - Southbound stays private — Product B never gets raw core-banking credentials; only platform MCP.
- Version the MCP schema — additive tools, deprecate slowly; Temporal workflows outlive any single frontend.
If you only wire southbound MCP (“agent talks to bank APIs”), your next product will either scrape your app or fork the money path. Expose the banking product as MCP from day one of the architecture, even if Product #2 is a year away.
Production hardening (the layer that keeps payday money safe)
The spine above is enough for a credible architecture review. Shipping real salary money needs these controls explicitly in the DSL, Temporal Activities, and ops runbooks — not as “we’ll add later” footnotes.
Partial failure — convert OK, transfer fails
A recipe is not atomic across rails. Design every multi-step rule as a state machine with named compensations:
| Step outcome | Product behavior |
|---|---|
| Convert succeeds, transfer fails | Leave converted balance parked (or auto-revert if policy says so); mark run PARTIAL; pause rule optional; notify user + support code |
| Transfer 1 OK, transfer 2 fails | Do not silently retry step 1; resume from failed step with same idempotency keys |
| All steps fail before ledger write | Mark FAILED; rule stays active unless circuit-breaker trips |
Temporal Activities should return structured results ({ status, ledgerTxnId, compensateHint }), not throw opaque errors. Keep a support playbook per PARTIAL code — agents never invent refunds in chat without an Activity.
In-flight pause — user pauses while a run is mid-flight
pause_rule must be a cooperative cancel, not delete-and-pray:
- Flip rule status
PAUSEDin DB (source of truth). - Signal any supervisor / cancel Schedule so new triggers do not start.
- In-flight
RuleRunWorkflow: honor a cancel Sync / cancellation scope between steps, never mid-Activity if the rail already committed. - If cancel arrives after convert but before transfer, treat as partial failure (above).
- UI: “Paused — last run finishing step 2/3” vs “Paused — no active run.”
Step-up auth — biometric / OTP
Enforce outside the LLM:
- First money-moving rule for a user → step-up (biometric / OTP / bank soft-token).
- Create / edit when notional or daily cap exceeds threshold → step-up again.
- Platform MCP
create_ruleinherits the same step-up requirement via session / assertion token — partners cannot skip it with a service key alone.
Store authAssertionId on the DSL version that was confirmed.
Payee / sanctions checks before new beneficiaries
Before the first transfer step to an unseen payee:
- Activity
screen_payee(sanctions / allowlist / cool-off). - Cold payees require confirm card + cooling period if policy demands.
- Cache screening result with TTL on
payeeId; Workflow agent only selects payees — screening is never “model said it looks fine.”
FX / slippage caps on convert steps
Encode in the DSL, enforce in the convert Activity:
convert: { from, to, amountOrPct, maxSlippageBps, quoteId?, abortOnBreach: true }
At execution: re-quote → if slippage vs card/quote exceeds maxSlippageBps, abort that run (rule stays active unless user chose “pause on FX abort”). Never silently fill at any price.
Webhook auth — signed webhooks, replay window
Salary / ledger ingress is a security boundary:
- Verify HMAC / mTLS signatures.
- Reject timestamps outside replay window (e.g. ±5 minutes).
- Deduplicate on provider
eventIdbefore startingRuleRunWorkflow. - Idempotency keys alone are not enough if you start ten workflows for ten replayed posts.
Observability — failed runs, override rate, queue lag
Ship metrics from day one of money paths:
rule_run_total{status=success|partial|failed|aborted_fx|aborted_guard}rule_run_duration_ms- Trigger → start lag (webhook received → first Activity)
- User overrides / edits per 100 runs
- Pause / cancel during in-flight count
Alert on partial-rate spikes and webhook lag. Chat UX can surface “last run: partial — see details”; dashboards own the ops truth.
DSL versioning — migrate old rules when schema changes
- Every confirm writes
dslVersion+ immutable snapshot. - Migrations are explicit jobs:
v1 → v2with dry-run report (how many rules need human re-confirm). - Breaking changes (new required FX cap) → force re-confirm card, do not silently inject defaults that move money differently.
- Execution always pins the snapshot version from rule creation/edit, not “latest code’s guess.”
Support replay — “why did rent send?”
Do not send support into the Temporal Web UI as the only answer.
Ship a receipt object per run:
ruleId,dslVersion,triggerEventId- step timeline with ledger txn ids
- guard / FX decisions
- pause/cancel flags
Rebuild the story from ledger + rule snapshot + receipt. Temporal history is for engineers when receipts disagree with reality.
When a unified agent is still OK
- Prototype week / 51-Hour Sprint with one demo path
- Query-only banking assistant (no money movement)
- Internal tools with one privileged operator and hard VPN gates
The moment payday money is real, split Query vs Workflow. Your future self on call will thank you.
How Xenqube scopes this
We ship this as AI Agent Development for fintech and neo-bank teams — often next to fiat-crypto rails and Xenith Agents patterns. If you have the chat UX but still run rules on crons and hope, Temporal is the upgrade that makes “say it once” true every payday.
Xenqube designs conversational → durable workflow systems for banking and fintech — Temporal under the chat, not vibes.
Read the case study → · Book a Discovery Call → · Banking AI Ops → · 51-Hour Sprint →