How to Build a compliance checking Agent Using LangGraph in TypeScript for pension funds
A compliance checking agent for pension funds reviews requests, documents, and transactions against policy rules before anything moves forward. It matters because pension operations sit under strict regulatory controls: contribution limits, benefit rules, data residency, retention, and auditability all need to be enforced consistently.
Architecture
Build this agent with a narrow, deterministic shape. For pension funds, the agent should not “decide” compliance from scratch; it should orchestrate rule checks, retrieve policy context, and produce an auditable decision package.
- •
Input normalizer
- •Converts raw request payloads into a typed state object.
- •Extracts fields like member ID, jurisdiction, transaction type, amount, and document references.
- •
Policy retrieval node
- •Pulls the relevant pension policy set from a controlled source.
- •Selects rules by fund, jurisdiction, product type, and effective date.
- •
Compliance checks node
- •Runs deterministic validations such as contribution caps, age restrictions, KYC completeness, and residency constraints.
- •Produces structured findings with rule IDs and severities.
- •
Escalation / exception node
- •Flags cases that need human review.
- •Handles ambiguous or missing data instead of guessing.
- •
Audit trail writer
- •Persists the full state transition history.
- •Stores inputs, outputs, rule versions, timestamps, and reviewer actions.
- •
Decision formatter
- •Returns a concise outcome: approved, rejected, or needs review.
- •Includes machine-readable reasons for downstream systems.
Implementation
1) Define the graph state and helper types
For this use case, keep the state explicit. Pension compliance is not a chat problem; it is a workflow problem with traceable inputs and outputs.
import { z } from "zod";
import { Annotation, END, StateGraph } from "@langchain/langgraph";
const ComplianceInputSchema = z.object({
fundId: z.string(),
jurisdiction: z.string(),
memberId: z.string(),
transactionType: z.enum(["CONTRIBUTION", "WITHDRAWAL", "TRANSFER"]),
amount: z.number().positive(),
documentIds: z.array(z.string()).default([]),
});
type ComplianceFinding = {
ruleId: string;
severity: "info" | "warn" | "block";
message: string;
};
const ComplianceState = Annotation.Root({
input: Annotation<{
fundId?: string;
jurisdiction?: string;
memberId?: string;
transactionType?: "CONTRIBUTION" | "WITHDRAWAL" | "TRANSFER";
amount?: number;
documentIds?: string[];
}>(),
policyVersion: Annotation<string | null>(),
findings: Annotation<ComplianceFinding[]>({
default: () => [],
reducer: (left, right) => [...left, ...right],
}),
decision: Annotation<"APPROVED" | "REJECTED" | "REVIEW" | null>(),
});
2) Add deterministic nodes for policy lookup and checks
Use plain functions for the actual compliance logic. LangGraph should coordinate the flow; your code should own the rules.
const loadPolicy = async (state: typeof ComplianceState.State) => {
const { fundId, jurisdiction } = state.input;
// Replace with database or config service lookup.
const policyVersion = `${fundId}:${jurisdiction}:2026.01`;
return { policyVersion };
};
const runChecks = async (state: typeof ComplianceState.State) => {
const findings: ComplianceFinding[] = [];
const { transactionType = "CONTRIBUTION", amount = 0 } = state.input;
if (transactionType === "CONTRIBUTION" && amount > 60000) {
findings.push({
ruleId: "PEN-CAP-001",
severity: "block",
message: "Contribution exceeds annual cap for this fund profile.",
});
}
if (!state.input.documentIds || state.input.documentIds.length === 0) {
findings.push({
ruleId: "PEN-KYC-004",
severity: "warn",
message: "No supporting documents attached for compliance review.",
});
}
if (state.input.jurisdiction === "EU" && transactionType === "TRANSFER") {
findings.push({
ruleId: "PEN-DATA-RESIDENCY-002",
severity: "block",
message: "Cross-border transfer requires residency validation.",
});
}
Code continues
const decide = async (state: typeof ComplianceState.State) => {
const hasBlocker = state.findings.some((f) => f.severity === "block");
const hasWarning = state.findings.some((f) => f.severity === "warn");
if (hasBlocker) return { decision: "REJECTED" as const };
if (hasWarning) return { decision: "REVIEW" as const };
return { decision: "APPROVED" as const };
};
export function buildComplianceGraph() {
const graph = new StateGraph(ComplianceState)
.addNode("loadPolicy", loadPolicy)
.addNode("runChecks", runChecks)
.addNode("decide", decide)
.addEdge("__start__", "loadPolicy")
.addEdge("loadPolicy", "runChecks")
.addEdge("runChecks", "decide")
.addEdge("decide", END);
return graph.compile();
}
What this does
The compiled graph gives you a predictable execution path:
- •Normalize input into
ComplianceState - •Load the correct policy version
- •Run deterministic checks
- •Produce a final decision
That is the right shape for pension funds because every outcome must be explainable later to internal audit or regulators.
Generate an execution result
You can invoke the graph from an API handler or queue worker.
import { buildComplianceGraph } from "./complianceGraph";
async function main() {
const app = buildComplianceGraph();
const result = await app.invoke({
input: {
fundId: "FUND-1024",
jurisdiction: "EU",
memberId: "MEM-7781",
transactionType: "TRANSFER",
amount: 25000,
documentIds: ["doc_1", "doc_2"],
},
});
console.log(result.decision);
console.log(result.findings);
console.log(result.policyVersion);
}
main();
Production Considerations
- •
Keep policy data versioned
- •Store every rule set with an effective date and immutable version ID.
- •When auditors ask why a request passed in March but fails in April, you need the exact policy snapshot used at runtime.
- •
Separate PII from model-facing logic
- •Minimize what enters the graph state.
- •Use internal IDs instead of names or national identifiers unless absolutely required.
- •
Enforce data residency at the infrastructure layer
- •Pension records often cannot leave specific regions.
- •Pin storage, queues, logs, and vector indexes to approved jurisdictions.
- •
Add human review thresholds
- •Anything involving blocked rules on withdrawals or transfers should route to a reviewer queue.
- •Do not let the agent auto-resolve ambiguous residency or beneficiary cases.
Common Pitfalls
- •
Using an LLM to invent compliance outcomes
- •Don’t ask the model “is this compliant?” without hard rules.
- •Use LangGraph to orchestrate deterministic checks first; reserve LLMs for summarization or document extraction only.
- •
Not persisting rule versions
- •If you only store the final decision, you lose auditability.
- •Persist
policyVersion, input payload hash, findings array, and timestamp for every run.
- •
Letting unstructured output drive downstream actions
- •Free-text explanations are not enough for pension operations.
- •Emit structured decisions like
APPROVED,REJECTED,REVIEWplus rule IDs so core systems can act safely.
Keep learning
- •The complete AI Agents Roadmap — my full 8-step breakdown
- •Free: The AI Agent Starter Kit — PDF checklist + starter code
- •Work with me — I build AI for banks and insurance companies
By Cyprian Aarons, AI Consultant at Topiax.
Want the complete 8-step roadmap?
Grab the free AI Agent Starter Kit — architecture templates, compliance checklists, and a 7-email deep-dive course.
Get the Starter Kit