Meridian Bank — Loan Approval System¶
Location: inception_recipes/meridian_bank/
Status: Production
Stack: React + Vite · Node.js/Express (×2) · FastAPI · LangGraph · Oracle ADB · OCI GenAI · FastMCP SQLcl
What it does¶
Meridian Bank automates first-pass intake and underwriting of consumer/small-business loan applications, keeping every non-trivial decision in front of a human loan officer. A customer chats with the bank; the swarm triages the conversation, answers product questions, or walks them through an application; a deterministic policy engine — never an LLM — classifies the outcome as auto-approved, auto-declined, or referred to a loan officer.
Customer → Meridian Website (chat) → concierge (triage)
├─ product question → products agent (rate sheet, read-only)
└─ "I want to apply" → loan_application (deterministic pipeline)
intake → memory → underwriting → decision
│
AUTO_APPROVE / AUTO_DECLINE ──── recorded, notified
REFER ─────────────────────────→ Loan Officer Queue
│
Loan Officer → Meridian Admin Console (IDCS SSO) → Officer Queue → live conversation trail → decide
Two independent frontend deployments, one shared backend — deliberately, not an oversight: the customer and the loan officer have genuinely different identity models (see Authentication).
Service Map¶
| Service | Port | Start command |
|---|---|---|
| SQLcl MCP server | 3001 |
.venv_mcp_sqlcl/bin/python -m src.server_token |
| Python FastAPI agents | 8789 |
uv run python -m src.api.loan_api |
| Admin console gateway | 8020 |
node server/index.js |
| Admin console UI (dev) | 8000 |
npm run dev |
| Customer website gateway + UI | 7002/5173 |
node server/index.js / npm run dev |
Swarm Topology¶
Modelled directly on the Swarm accelerator pattern: three peers, fully connected, no central router. concierge/products can hand off into loan_application any time — the handoff is a same-turn jump, so "I'd like to apply for $15k" gets one coherent reply, not "transferring you, please ask again."
concierge <────────────────────────> products
│ (transfer_to_products) │
│ (transfer_to_loan_application) │ (transfer_to_loan_application)
└──────────────► loan_application ◄───┘
| Peer | Pattern | Role |
|---|---|---|
concierge |
create_oci_agent, no DB tools |
General Q&A + triage, the swarm's default entry point. Never states a rate, fee, or decision itself. |
products |
create_oci_agent, scoped read-only LOAN_PRODUCTS/LOAN_PRODUCT_RATE_TIERS |
Answers product/pricing questions — always a range per credit tier, never a personalized single number |
loan_application |
Not an LLM agent — a plain Python node wrapping the unchanged intake→memory→underwriting→decision pipeline | Giving that inner pipeline's own chat agent live handoff tools would need a second level of Command.PARENT bubbling (LangGraph doesn't do that automatically) and would let an LLM decide when an application is "done" — eroding the one thing this recipe deliberately protects: zero LLM discretion at the approve/decline/refer boundary. Its own handoff is a small, directly unit-tested pure function instead. |
InjectedState gotcha
A tool inside concierge/products (their transfer_to_<agent> handoff tools) only ever sees that peer's own narrow {"messages": [...]} schema — never the outer swarm's session_id/authenticated-customer-id, even though they were present in what the swarm passed into the node. Meridian sets a module-level _turn_context from a thin per-node wrapper immediately before invoking each peer's compiled graph, so the handoff tool's own audit_log call still gets a real user_id/session_id instead of silently logging null. See Swarm — Peer-to-Peer Handoff for the general pattern.
Authentication¶
Two distinct identity models, one per UI — deliberately, not an oversight:
| Actor | Flow | Notes |
|---|---|---|
| Loan Officer (admin console) | IDCS Authorization Code → HttpOnly session cookie, gated by IDCS group membership | Every officer-queue read/write uses that officer's own bearer token all the way to MCP SQLcl — full identity propagation, no shared credential |
| Applicant (customer website) | Username/password against LOAN_CUSTOMER_USERS (bcrypt), proven at registration by matching Applicant ID + email against an existing LOAN_APPLICANTS record |
No IDCS identity — a shared service bearer token (OCI API-key exchange, service_token.py) carries the DB connection instead, the same non-interactive mechanism the CLI uses. LOAN_AUDIT_LOG.ACTOR records 'AGENT' for these, not an individual person — every write is still attributed at the application layer to the authenticated applicant_id/session_id |
| CLI / service calls | OCI API-key token exchange (RFC 8693), same client as the customer portal's service token |
Deterministic Decision Boundary¶
decision_agent.evaluate_policy() is plain Python — no LLM call, no model non-determinism at the single highest-risk decision point in the recipe:
if profile.bankruptcy_flag:
return AUTO_DECLINE
if profile.credit_score < POLICY_AUTO_DECLINE_MAX_CREDIT_SCORE:
return AUTO_DECLINE
if profile.credit_score >= MIN and profile.dti_ratio <= MAX_DTI and amount <= MAX_AMOUNT:
return AUTO_APPROVE
return REFER # everything else needs a human loan officer
The underwriting deep-research agent (gemini-2.5-pro, create_deep_research_agent) only ever produces an explanatory narrative — it never influences the tier. REFER-tier cases block in LOAN_DECISIONS (PENDING_REVIEW) until a loan officer acts through the Officer Queue dashboard; there is no automatic timeout-to-approve.
Memory Architecture¶
| Store | Type | Namespace | Contents |
|---|---|---|---|
OracleDBSaver |
Short-term | keyed by session_id (the swarm's outer graph) |
Full cross-agent conversation — concierge triage, product questions, and the application, all in one trail (what the Officer Console's live transcript view reads) |
OracleDBSaver |
Short-term | loan-intake::<session_id> / loan-underwriting::<session_id> |
The loan_application peer's own inner chat/research-agent memory, namespaced distinctly from the raw session_id so three unrelated compiled graphs sharing one Oracle table never collide on checkpoint storage |
OracleStore |
Long-term | ("applications",) |
In-progress LoanApplicationEntity, keyed by session_id |
OracleStore |
Long-term | ("decisions",) |
Cached decision, keyed by application_id — the memory-agent cache-hit router |
Observability¶
Three complementary layers, each aimed at a different audience:
- Compliance record —
LOAN_AUDIT_LOG, a durable, queryable table written viaaudit_log()at every workflow transition. - Engineering trace — LangFuse (
src/observability/langfuse_tracing.py), following the framework's "Core Pattern" directly against thelangfuseSDK. - Officer-facing live activity log — customers only ever see the agent's final reply; loan officers get a running "Agent Activity Log" tab backed by an in-memory ring buffer (
log_buffer.py) attached to every agent logger.
Evaluation (AE-09)¶
tests/evals/ is wired to metro.evals.evalFactory (see Continuous Evaluation) — four case files, each mapped to a different kind of change:
| File | Layers | Covers |
|---|---|---|
cases/decision_policy.yaml |
assertion |
The three seeded applicants' real AUTO_APPROVE/AUTO_DECLINE/REFER outcomes, direct against decision_agent.evaluate_policy — pure, no LLM |
cases/products_agent.yaml |
trajectory, groundedness |
Includes a permanent regression case for a real bug found in this recipe: the fast model narrating a SQL query instead of calling it, then fabricating a rate from the narration |
cases/routing.yaml |
trajectory |
Swarm handoff correctness — cheap, no LLM judge needed |
cases/adversarial.yaml |
assertion, trajectory, groundedness |
Prompt injection, other-customer data leakage, fabrication-under-pressure, the REFER-bypass boundary |
uv run pytest tests/evals is the CI gate — separate from uv run pytest tests/unit, which stays fast with no live-service dependency.
Startup Sequence¶
# 1 — SQLcl MCP server
cd inception_mcp_servers/mcp_sqlcl
.venv_mcp_sqlcl/bin/python -m src.server_token
# 2 — Python FastAPI agents
cd inception_recipes/meridian_bank/meridian_bank_agents
uv run python -m src.api.loan_api
# 3 — Admin console (Loan Officer)
cd inception_recipes/meridian_bank/meridian_admin_ui
node server/index.js
npm run dev # → http://localhost:8000
# 4 — Customer website
cd inception_recipes/meridian_bank/meridian_customer_ui
node server/index.js
npm run dev
Or drive the swarm without any UI:
Key Design Decisions¶
| Decision | Rationale |
|---|---|
loan_application is a plain Python node, not an LLM swarm peer |
Protects the zero-LLM-discretion decision boundary — see Swarm Topology |
| Two independent UI deployments sharing one backend | Customer and Loan Officer have genuinely different identity models — see Authentication |
REFER resolved via a Loan Officer dashboard, not next-turn chat HITL |
A financial decision shouldn't be re-triggerable by applicant chat pressure — contrast with Smart Dispatch's dispatch-confirmation pattern |
| Officer Console shows the live conversation trail, not just the final reply | The outer swarm graph checkpoints under the raw session_id for exactly this reason |
Every audit_log()/get_agentic_logger() call passes user_id=/session_id=/application= explicitly |
Neither is ever auto-populated from ambient context — an omitted kwarg silently logs null/the wrong application with no error. See Inception Skills' hard guardrails |