Skip to content

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:

  1. Compliance record — LOAN_AUDIT_LOG, a durable, queryable table written via audit_log() at every workflow transition.
  2. Engineering trace — LangFuse (src/observability/langfuse_tracing.py), following the framework's "Core Pattern" directly against the langfuse SDK.
  3. 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:

cd inception_recipes/meridian_bank/meridian_bank_agents
uv run python -m src.cli.meridian_bank

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

Full Architecture Reference

inception_recipes/meridian_bank/specs/SPEC.md       ← AE-01..AE-15 specification
inception_recipes/meridian_bank/docs/architecture.md ← system diagram, memory namespaces