ProjectsLiquid Capital — Treasury Operations Copilot
Case Study · FinTech AI · Agentic Orchestration

Liquid Capital — A Treasury Copilot That Can't Move Money Itself

A multi-agent LangGraph backend that analyzes liquidity, retrieves grounding policy, and proposes transfers — gated by a deterministic risk layer and a human-in-the-loop approval step before anything executes. A Next.js dashboard sits on top of it.

LangGraphFastAPIClaudePineconeNext.jsSQLModelLangSmithVitest
Role
Full-stack build, agent orchestration & guardrail design
Domain
FinTech · treasury operations
Primary Services
Claude API · LangGraph · Pinecone
01

The problem & requirements

Functional
  • Analyze current liquidity position and cash forecast, grounding any recommendation in the applicable treasury policy text
  • Classify a free-text request into one of five intents — shortfall analysis, explicit transfer, status inquiry, payment inquiry, off-topic — and route accordingly
  • Propose a transfer recommendation with a from/to account, but never execute one without a human sign-off
Non-functional
  • Deterministic risk gate: transfer limits, reserve floor, and approval thresholds are decided by plain Python, never inferred by the model
  • Structural tool boundary: only read tools are ever bindable to the LLM — nothing that moves money is a tool the model can call itself
  • Server-side authorization: approver identity is checked in the API, not just gated in the UI
02

Scale & constraints

A practice build for a live technical prototype challenge, scoped around a single rule from the original planning doc: "complexity has to buy something." SQLite over a hosted DB, Pinecone with an in-memory fallback — the project runs end-to-end with zero external setup beyond one Anthropic API key.

4
agent nodes fanning out in parallel from a single START
10 + 42
backend pytest + frontend Vitest tests, all passing
5
golden-eval cases run through the real graph, one per intent
03

API design

POST /api/analyze → 200 | 502
Runs the graph, persists a `Decision` + audit entry. Returns 502 with the graph's accumulated errors if the run failed without producing a recommendation — no fabricated fallback data.
PATCH /api/approve/{decision_id} → Decision
Checks `get_user_permissions` before allowing an approve decision. `403` if the approver isn't authorized, `409` if the decision was already resolved.
GET /api/liquidity → LiquiditySnapshot
Read-only, using the same tools the Liquidity Agent calls — exposed directly so the dashboard renders before any chat turn happens.
GET /api/decisions/{decision_id} → Decision
Fetch a persisted decision by id, including its current status and audit history.
04

Data model

EntityKey fields
DecisionRequest, liquidity analysis, retrieved policies, and recommendation stored as JSON columns, plus `status: pending|executed|rejected`.
AuditLogOne row per lifecycle event — `recommended`, `executed`, `rejected` — with the acting user.
GraphState (LangGraph)TypedDict with an `Annotated[list[str], operator.add]` reducer on `errors`, since multiple parallel branches can each append in the same step.

intent, risk_assessment, and payment_analysis are computed per-request and returned in the API response but intentionally not persisted — they're graph-internal reasoning, not the audit-relevant record. Only the decision and its lifecycle events are durable.

05

Architecture

Parallel fan-out, deterministic join

Four agents can run in parallel off a single request; one deterministic validator — not an LLM — is the only place that decides whether a human needs to sign off.

START
💰
liquidity_agent
📄
policy_agent
intent_parser
🗄
payments_agent *
risk_validator
Liquidity Agent
Reads balances + cash forecast, computes projected shortfall/surplus
Policy Agent
RAG retrieval of the top-k relevant policy chunks for the request
Intent Parser
The graph's only LLM call — classifies into 1 of 5 types via structured output
Payments Agent *
Conditional — joins only if a keyword pre-filter matches, no second LLM call
`risk_validator` is pure deterministic Python, not an LLM call — it's the sole place `approval_required` gets set, branching on intent type against transfer limits, reserve floor, and a payment-batch approval threshold from `tools/limits.py`.

Fig. 1a — ★ Payments Agent joins the parallel wave only when a cheap keyword check on the raw request text matches, decided before any LLM call runs.

Deploy topology

No CORS configuration on the backend by design — the frontend proxies through its own route handlers instead, so the backend URL is never exposed to the browser.

Next.js
Vercel
/api/proxy/*
same-origin
FastAPI
Render
🗄
SQLite
local disk
🔎
Pinecone
optional
🛰
LangSmith tracing
env-var only
Every dependency past Claude itself is optional at runtime. No Pinecone key falls back to in-memory keyword-overlap retrieval over the same policy chunks; no LangSmith key silently skips tracing rather than failing requests.

Fig. 1b — SQLite persists to local disk; doesn't survive a redeploy on a standard ephemeral-filesystem host without a persistent volume.

06

Key decisions & trade-offs

Trade-off. Payments Agent gated by a keyword pre-filter, not a second LLM call. A payment-related request phrased without any of those keywords skips the branch even if intent_parser would have classified it correctly — an accepted, documented gap rather than paying for a second classification call on every request.
Invariant. Only read tools are ever bindable to the LLM. Transfer limits, reserve floors, and approval authorization live in plain Python called directly by risk_validator and /api/approve — never exposed as an LLM-callable tool. The model proposes; the code decides.
Constraint. The payment-inquiry path never sets approval_required. It compares the aggregate against a policy threshold and states in the response text whether sign-off is needed, but stays informational-only since no transfer is actually being proposed.
Bootstrap-first. SQLite over a hosted DB, Pinecone with an in-memory fallback. Both chosen so the project runs with zero external setup beyond one Anthropic API key — complexity has to buy something, per the original scoping doc.
Trade-off. The Agent Activity Feed simulates the step sequence rather than truly streaming. The backend graph run is synchronous with no streaming endpoint, so the feed shows the known step order only for the duration of the actual in-flight request, then reconciles against what the response shows evidence of having run.
Verification. Every UI change verified against a real running app, not just type-checked. Both servers driven with a headless browser in light and dark mode, checking for console errors — a different signal than unit tests run in isolation.
07

Guardrails

Deterministic risk gate, not an LLM decision

risk_validator is pure Python — transfer limits, reserve floor, and approval thresholds are evaluated in code, never inferred by the model.

Model can't call money-moving tools

Only get_account_balances, get_cash_forecast, and get_upcoming_payments are ever bindable to the LLM; limits.py is never exposed as a tool.

Server-side authorization, not just a UI gate

PATCH /api/approve checks get_user_permissions — an operator attempting self-approval gets a real 403, not a hidden button.

No fabricated fallback data

/api/analyze returns 502 with the graph's accumulated errors on failure, rather than a plausible-looking but invented recommendation.

Double-approval protected

Resolving an already-resolved decision returns 409, not a silent overwrite of the prior outcome.

Audit trail on every lifecycle event

AuditLog records recommended / executed / rejected with actor, distinct from the Decision's own stored analysis.

08

Lessons learned

💡 What actually held up
  • Keeping the risk boundary in `tools/limits.py` as plain, non-bindable Python means the model literally cannot execute a transfer no matter how it's prompted — a structural guarantee, not a prompted one.
  • Returning an identical response shape from both RAG paths (Pinecone vs. in-memory fallback) meant policy_agent never needed to know which backend served a request — zero external accounts required to demo the retrieval story end to end.
  • Verifying every UI change against a real running app — both servers, headless browser, light and dark mode — caught console errors that type-checking and isolated unit tests wouldn't have.
What I'd do differently
  • The Payments Agent's keyword gate is a known, documented gap, not a fixed one — a payment request phrased without those specific words silently skips the branch. With more time I'd add a cheap classifier instead of hand-picked keywords.
  • No real authentication — the approver identity is a UI dropdown standing in for a session system. The authorization check itself is real and server-side; only the identification of who's asking isn't.
  • The Agent Activity Feed simulates a step sequence rather than truly streaming, since the backend has no streaming endpoint. A real SSE/WebSocket stream would remove the reconciliation step entirely.
🔁If I started this over: give the graph a real streaming endpoint from day one, so the frontend reflects actual node execution instead of simulating a known sequence and reconciling after the fact.
System
Liquid Capital — Treasury Operations Copilot
Primary services
Claude API · LangGraph · Pinecone
Status
Live demo on Vercel · SQLite non-durable across redeploys
Type
FinTech AI · agentic orchestration

Interested in the architecture behind this or another project?