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.
The problem & requirements
- 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
- 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
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.
API design
Data model
| Entity | Key fields |
|---|---|
| Decision | Request, liquidity analysis, retrieved policies, and recommendation stored as JSON columns, plus `status: pending|executed|rejected`. |
| AuditLog | One 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.
Architecture
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.
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.
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.
Fig. 1b — SQLite persists to local disk; doesn't survive a redeploy on a standard ephemeral-filesystem host without a persistent volume.
Key decisions & trade-offs
intent_parser would have classified it correctly — an accepted, documented gap rather than paying for a second classification call on every request.risk_validator and /api/approve — never exposed as an LLM-callable tool. The model proposes; the code decides.Guardrails
risk_validator is pure Python — transfer limits, reserve floor, and approval thresholds are evaluated in code, never inferred by the model.
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.
PATCH /api/approve checks get_user_permissions — an operator attempting self-approval gets a real 403, not a hidden button.
/api/analyze returns 502 with the graph's accumulated errors on failure, rather than a plausible-looking but invented recommendation.
Resolving an already-resolved decision returns 409, not a silent overwrite of the prior outcome.
AuditLog records recommended / executed / rejected with actor, distinct from the Decision's own stored analysis.
Lessons learned
- 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_agentnever 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.
- 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.