Patrick Attankurugu
Patrick Attankurugu
HomeProjectsBlogTech Stack
CV
Patrick Attankurugu
Patrick Attankurugu

Building AI for financial compliance across Africa

HomeProjectsBlogTech StackRamah Foundation

© 2026 Patrick Attankurugu. All rights reserved.

Back to blog
AI Agents · LangGraph

Building Autonomous Compliance Agents with LangGraph

September 2024 · revised July 2026·15 min read

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.

6
specialized LLM agents
10
nodes in the state machine
4
agents running in parallel
0
real customer records (synthetic by design)

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.

SENTINEL's LangGraph topology: an alert enters a deterministic triage node, fans out in parallel to four LLM agents (research, document analysis, regulatory, network analysis), fans back into a risk assessment agent, then a decision node routes by score: 75 and above drafts a SAR into maker-checker review, 40 to 74 escalates to a human analyst, below 40 auto-closes with an audit trail.
Figure 1. The investigation graph. Triage is deliberately not an LLM; the four investigation agents run in parallel; the decision node's routing thresholds are code, not prompt.

The six agents, and what each one actually consumes and produces:

AgentReadsProduces
Researchcustomer + alert, watchlist and PEP lookupsmatches with source and confidence per finding
DocumentKYC fieldsconsistency checks, expiries, anomalies, authenticity score
Regulatorycustomer + alert, regulation knowledge baseapplicable rules (CBN, FATF, EU, FinCEN), CDD/EDD level, reporting duties
Networkup to 200 transactionstypologies detected, graph nodes and edges, risk contribution
Risk assessmentall four findings sets0-100 score, weighted factors, reasoning chain, recommendation
SAReverything aboveNFIU/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.

Before and after of the state-merge bug. Before: four parallel agents write whole values to the same state keys (status_updates, current_agent) and LangGraph raises InvalidConcurrentGraphUpdate. After: the fields are annotated with reducers, lists merge via operator.add, current_agent takes the latest value, and nodes return only their delta.
Figure 2. Concurrent branches finishing in the same superstep both write to shared keys. The fix is not a lock; it is declaring, per field, how writes combine.

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:

Streaming path: a FastAPI background task runs the LangGraph investigation with astream, yielding one state delta per completed node; each is published to a Redis pub/sub channel named investigation:{id}:events; a WebSocket endpoint subscribes and relays JSON events to the React investigation theater. Example events shown are agent_completed for research and network analysis, then a decision event with route sar_generation and risk score 88.
Figure 3. LangGraph astream to Redis pub/sub to WebSocket to React. Node-level events, not token streams.

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.

Two-layer architecture diagram. Top LLM layer (Gemini 2.5 Flash, temperature 0.2, JSON mode): agent narratives, cross-agent risk synthesis, copilot function-calling with five read-only tools, embedding-based case similarity. Bottom deterministic layer: typology detectors with hardcoded thresholds, additive risk weights (sanctions +40, PEP +20), a keyword intent router, and TF-IDF cosine similarity. Dashed arrows show each LLM capability degrading to its deterministic counterpart when no API key is present.
Figure 4. Every LLM capability has a deterministic floor under it. Unplug the API key and investigations still run end to end; they just read like a form instead of a memo.

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:

Per-typology precision and recall bars from the live evaluation endpoint: structuring, round-tripping, and high-risk jurisdiction at precision 1.0 and recall 1.0; rapid movement at precision 0.5, recall 1.0; layering at precision 0.2, recall 1.0, flagged as over-firing with four false positives; velocity anomaly with no labeled support. Macro precision 0.74, recall 1.0, F1 0.8. A caveat notes ground truth is the seeder's own scenario labels.
Figure 5. Live output of the evaluation endpoint, macro precision 0.74 at recall 1.00. The layering detector over-fires, and the dashboard says so instead of hiding it.

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)
Patrick Attankurugu
Patrick Attankurugu
Senior AI Engineer at Agregar Technologies, building production AML, KYC, and compliance AI systems for African financial institutions. Builder of SENTINEL and AfricaPEP.