Building Autonomous Compliance Agents with LangGraph
SENTINEL is a compliance AI I built and run as a live, public demo: six specialized Gemini agents on a LangGraph state machine that take an AML alert from triage through parallel investigation to a drafted Suspicious Activity Report, streamed to the browser agent by agent. You can run an investigation yourself right now. This post is the architecture, the two bugs that taught me the most about multi-agent systems, and the design rule I now apply to every LLM system in a regulated domain.
One framing note before the architecture, because this post used to get it wrong. An earlier version cited accuracy-versus-analyst numbers from a parallel-run trial I cannot substantiate, and they are gone. SENTINEL runs on a deliberately synthetic dataset: eight seeded customers, each engineered to exhibit one laundering typology (structuring under the NGN 5,000,000 reporting threshold, cross-border layering, round-tripping, a sanctions near-match, and so on), plus clean controls. That is not a weakness to hide; it is what lets a compliance system be demoed publicly at all, and it enables the honest self-grading in the last section.
A workflow graph, not a chat loop
The fashionable way to build an “agent” is a loop: give one LLM every tool, let it decide what to do next, repeat until it declares itself done. For a compliance investigation that design is wrong on its face. An AML investigation is a procedure. A regulator expects the same alert to trigger the same steps every time, and an auditor expects to see which step produced which finding. The judgment inside each step is where the model earns its keep; the sequence of steps is not the model's to improvise.
So SENTINEL's orchestration is a fixed LangGraph state machine: ten nodes, one shared state object, LLM judgment inside the nodes, deterministic routing between them.
The six agents, and what each one actually consumes and produces:
| Agent | Reads | Produces |
|---|---|---|
| Research | customer + alert, watchlist and PEP lookups | matches with source and confidence per finding |
| Document | KYC fields | consistency checks, expiries, anomalies, authenticity score |
| Regulatory | customer + alert, regulation knowledge base | applicable rules (CBN, FATF, EU, FinCEN), CDD/EDD level, reporting duties |
| Network | up to 200 transactions | typologies detected, graph nodes and edges, risk contribution |
| Risk assessment | all four findings sets | 0-100 score, weighted factors, reasoning chain, recommendation |
| SAR | everything above | NFIU/CBN-format draft with filing priority |
Two structural choices matter more than the roster. Triage is a deterministic node, not an agent: deciding whether an alert warrants investigation at all is exactly the kind of policy-with-consequences that should not vary with a sampling seed. And specialization here is not about accuracy folklore; it is about auditability. When findings are wrong, I need to know which prompt, which inputs, and which node produced them, and a six-way split makes that question answerable.
The bug that taught me how LangGraph state really works
The four investigation agents are independent, so they run as a genuine parallel fan-out: triage has four outgoing edges, and risk assessment waits on all four. The moment I turned that on, the graph started throwing InvalidConcurrentGraphUpdate.
The cause is fundamental to how LangGraph executes: parallel branches complete in the same superstep, and each returned a full updated copy of shared fields like the status timeline and the “current agent” marker. Two writers, one key, no merge rule. The fix is the idiom I now reach for first in any LangGraph design: make every concurrently-written field declare its own merge semantics, and make nodes return deltas rather than accumulated state.
class InvestigationState(TypedDict):
investigation_id: str
alert_data: dict
customer_data: dict
transactions: list
research_findings: dict | None # each written by exactly
document_findings: dict | None # one node: no reducer
regulatory_findings: dict | None # needed
network_findings: dict | None
risk_assessment: dict | None
sar_draft: dict | None
decision: str | None
# written by EVERY node, concurrently: reducers required
errors: Annotated[list[str], operator.add]
status_updates: Annotated[list[dict], operator.add]
current_agent: Annotated[str | None, take_latest]
# a node contributes a delta; the reducer owns the merge
return {"status_updates": [{"agent": "research", "status": "completed"}]}The deeper lesson: a multi-agent state object is a small distributed system, and the reducer annotation is its conflict resolution policy. Fields written by one node need nothing. Fields written by many need an explicit rule, and “append to a list” and “last write wins” cover almost every real case. If you find yourself wanting a lock, your nodes are returning too much state.
Parallelism surfaced a second, unglamorous constraint: rate limits. Four agents firing simultaneously at the Gemini API is a burst profile that free-tier quotas notice. The whole pipeline shares one asyncio.Semaphore (default concurrency 2) and a retry wrapper that backs off exponentially with jitter on 429 and 503, capped at 30 seconds. All agents run at temperature 0.2 with native JSON response mode, and every response is parsed, not trusted: a parse failure marks the finding as degraded rather than crashing the investigation.
Streaming: how “watch the agents work” actually works
The demo's signature feature is the investigation theater: you press run, and the four agents visibly complete one by one, each dropping its summary into the timeline. The plumbing is three well-known pieces in a row rather than anything exotic:
The investigation runs as a FastAPI background task and iterates graph.astream(state), which yields one state delta per completed node. Each delta becomes an event on a Redis pub/sub channel keyed by investigation id, and a WebSocket endpoint relays that channel to the browser. Redis in the middle looks like an extra hop until you notice what it buys: the run does not care whether anyone is watching, refreshing the page mid-run just resubscribes, and a second viewer costs nothing.
Streaming at node granularity rather than token granularity was a product decision I would defend anywhere: a compliance reviewer needs “the network agent found a layering pattern,” not a live feed of the model typing. It also decouples the UI protocol from prompt changes, which in practice change weekly.
The rule: the LLM narrates, it does not hold up the building
Here is the design decision I would defend hardest in a review, and the one the earlier version of this post dressed up in invented “hallucination detection” language. In SENTINEL, everything that must be correct is deterministic, and the LLM is layered on top to explain, synthesize, and converse.
Concretely: the six typology detectors (structuring, layering, round-tripping, rapid movement, velocity anomalies, high-risk corridors) are pure Python over the transaction graph, with thresholds in code, like flagging cash deposits at 60 to 99 percent of the NGN 5,000,000 reporting line within a three-day window. The risk score is additive arithmetic with visible weights: sanctions match +40, PEP +20, high-severity document issue +15, alert priority and typology severities on top, capped at 100. The SAR has a template fallback. The LLM writes the narrative around these facts and synthesizes them into a reasoning chain, and the frontend renders the score as a waterfall of weighted factors, each carrying its evidence, because “88, critical” is not an explanation but “+40 sanctions near-match, +25 layering, +15 critical alert” is one an auditor can argue with.
This layering is also what makes the system honest under failure. No API key, a quota exhaustion, a parse error: every path degrades to its deterministic floor instead of breaking or, worse, quietly making things up. In a regulated domain I now treat this as the baseline architecture for LLM systems: the model is the narrator and the synthesist, never the only thing standing between an alert and a decision.
Humans are nodes too
The graph's decision node routes by score: 75 and above drafts a SAR, 40 to 74 escalates to a human analyst, below 40 auto-closes with an audit log. But the more important boundary is what the system cannot do at all. A drafted SAR enters a maker-checker lifecycle (draft, under review, approved, filed) where submission and approval are separate human actions, every transition writes an audit log with the actor recorded, and a 72-hour regulatory clock starts at drafting. “Filing” sets statuses and timestamps and generates the NFIU/CBN-style PDF; nothing is ever transmitted to a regulator. The agents accelerate the investigation. The signature stays human.
Honesty requires the other half: the demo has no authentication wall, so the maker-checker separation is modeled in the data layer rather than enforced by access control. For a public portfolio demo that is the right trade; for production it is the first thing I would build, and the audit-log plumbing it would attach to already exists.
Two agent patterns in one system
SENTINEL 3.0 added a copilot, and the contrast between it and the pipeline is the clearest illustration I have of when to use which agent pattern. The investigation pipeline is a fixed DAG because investigations are procedures. The copilot is the opposite: a single conversational Gemini agent with genuine function-calling over five strictly read-only tools (platform overview, top risk investigations, typology search, SAR pipeline status, customer lookup), looping up to five tool calls per question at temperature 0.1. Ask “which customers show layering patterns above risk 80” and it plans the tool calls itself.
The pattern rule that falls out: when the process is the product (an investigation someone must audit), fix the graph and put the model inside the nodes. When the question is the product (an analyst exploring live data), let the model drive the tools, but make every tool read-only and small. And true to the layering rule above, the copilot also degrades without an API key, to a keyword router over the same five tools.
Grading the system in public
The earlier version of this post claimed an 87 percent agreement rate with human analysts. The honest replacement is better engineering and a worse headline: SENTINEL ships an evaluation dashboard that grades its own typology engine against the seeded scenarios' labels, live, with per-class precision and recall. Here is what it reported the day I revised this post:
Read it critically, because that is the point. The layering detector catches its target case but also fires on four others: precision 0.20, a recall-first tuning whose cost is now visible on a dashboard instead of buried in a config file. Velocity anomaly has no labeled case in the current seed, and the dashboard reports that as zero support rather than padding it. And the deepest caveat is printed on the page itself: the ground truth is the synthetic seeder's own scenario labels, so this measures whether the detectors catch what the data was engineered to contain, which is internal consistency, not field performance. A system that grades itself honestly on synthetic data earns the right to be trusted with real evaluation later; a system that quotes impressive numbers with no harness does not.
What I would build next
- A checkpointer. The graph currently compiles without persistence, so a crashed investigation restarts rather than resumes. LangGraph's checkpointing exists precisely for this, and long investigations deserve it.
- Auth in front of the maker-checker flow, turning the modeled role separation into an enforced one.
- Real retrieval for the regulatory agent. Its knowledge base is curated JSON per jurisdiction today, which is transparent and auditable but fixed; citation-grounded retrieval over full regulatory texts is the upgrade path, and the harness to evaluate it already exists.
- Cross-linking with real screening data. The research agent's watchlists are synthetic samples; pointing it at AfricaPEP, which is real and live, is the obvious marriage of my two systems.
The system is live at sentinel.patrickaiafrica.com: load the demo data, open an investigation, press run, and watch the graph in Figure 1 execute for real, then ask the copilot to summarize what it found. For the surrounding thinking, the case for ML in monitoring is in the AML piece and the name-matching engine SENTINEL's research agent imitates in miniature is in the entity resolution deep dive.
Sources and further reading
- LangGraph documentation (state, reducers, and streaming semantics used throughout)
- Gemini API function calling documentation (the copilot's tool-calling loop)
- FinCEN, Guidance on Preparing a Complete and Sufficient SAR Narrative (2003) (what a SAR narrative must actually contain)
- FATF Recommendations (R.20 is the suspicious transaction reporting obligation the SAR agent serves)