How to Build a policy Q&A Agent Using LangGraph in TypeScript for fintech
A policy Q&A agent answers questions like “Can I reimburse this expense?” or “Is this transfer allowed under our AML policy?” by retrieving the right policy text, reasoning over it, and returning a grounded answer with citations. For fintech, that matters because policy drift, inconsistent human interpretation, and weak auditability turn into compliance risk fast.
Architecture
Build this agent as a small graph, not a single prompt.
- •
User input node
- •Normalizes the question
- •Captures metadata like tenant, jurisdiction, and user role
- •
Policy retrieval node
- •Pulls relevant policy chunks from a vector store or document index
- •Filters by jurisdiction, product line, and effective date
- •
Policy reasoning node
- •Uses an LLM to answer only from retrieved policy context
- •Forces citations to source sections
- •
Compliance guardrail node
- •Checks for disallowed outputs
- •Blocks answers that look like legal advice or unsupported claims
- •
Audit logging node
- •Stores question, retrieved docs, model output, and final decision
- •Supports post-incident review and regulator requests
- •
Human escalation node
- •Routes ambiguous or high-risk questions to compliance ops
- •Handles cases where confidence is low or policy is missing
Implementation
1) Define state and build the graph
In LangGraph, keep the state explicit. For fintech, your state should carry the question, retrieved evidence, draft answer, and audit fields.
import { StateGraph, Annotation, START, END } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";
const PolicyAnswerState = Annotation.Root({
question: Annotation<string>(),
jurisdiction: Annotation<string>(),
role: Annotation<string>(),
retrievedContext: Annotation<string[]>(),
draftAnswer: Annotation<string>(),
finalAnswer: Annotation<string>(),
needsEscalation: Annotation<boolean>(),
});
const llm = new ChatOpenAI({
model: "gpt-4o-mini",
temperature: 0,
});
type PolicyDoc = {
id: string;
text: string;
};
async function retrievePolicies(question: string, jurisdiction: string): Promise<PolicyDoc[]> {
// Replace with your vector store / search backend.
return [
{ id: "policy-expenses-001", text: "Expense reimbursement requires receipt for amounts over $25." },
{ id: "policy-payments-014", text: "Cross-border transfers require AML screening and sanctions checks." },
].filter((doc) => doc.text.toLowerCase().includes("expense") || jurisdiction === "global");
}
const graph = new StateGraph(PolicyAnswerState)
2) Add retrieval and answer nodes
Keep retrieval deterministic. The LLM should not invent policy text; it should only synthesize from the retrieved context.
graph.addNode("retrieve", async (state) => {
const docs = await retrievePolicies(state.question, state.jurisdiction);
return {
retrievedContext: docs.map((d) => `[${d.id}] ${d.text}`),
needsEscalation: docs.length === 0,
};
});
graph.addNode("answer", async (state) => {
const context = state.retrievedContext.join("\n");
const prompt = `
You are a fintech policy assistant.
Answer only using the provided policy context.
If the context is insufficient, say you need escalation.
Cite the policy IDs in brackets.
Question: ${state.question}
Role: ${state.role}
Jurisdiction: ${state.jurisdiction}
Policy context:
${context}
`;
const result = await llm.invoke(prompt);
return { draftAnswer: result.content.toString() };
});
3) Add guardrails and routing logic
This is where most fintech teams get serious about control. If the model produces unsupported claims or if retrieval returns nothing relevant, route to escalation.
graph.addNode("guardrail", async (state) => {
const schema = z.object({
supportedClaimCount: z.number().min(0),
hasCitation: z.boolean(),
riskyLanguage: z.boolean(),
escalate: z.boolean(),
});
const analysisPrompt = `
Analyze this answer for fintech compliance risk.
Return JSON with supportedClaimCount, hasCitation, riskyLanguage, escalate.
Answer:
${state.draftAnswer}
`;
const analysis = await llm.invoke(analysisPrompt);
const parsed = schema.parse(JSON.parse(analysis.content.toString()));
return {
needsEscalation: state.needsEscalation || parsed.escalate || !parsed.hasCitation || parsed.riskyLanguage,
finalAnswer: parsed.escalate ? "This question needs compliance review." : state.draftAnswer,
};
});
graph.addConditionalEdges("retrieve", (state) => (state.needsEscalation ? "escalate" : "answer"), {
answer: "answer",
});
graph.addEdge("answer", "guardrail");
graph.addConditionalEdges("guardrail", (state) => (state.needsEscalation ? "escalate" : END), {
})
graph.addNode("escalate", async (state) => ({
})
);
The last part above should be wired cleanly in your codebase; here’s the complete pattern you want in practice:
graph.addNode("escalate", async (state) => {
return {
finalAnswer:
"I couldn't verify this against current policy. Escalating to compliance ops.",
};
});
graph.addEdge(START, "retrieve");
graph.addConditionalEdges("retrieve", (state) =>
state.needsEscalation ? "escalate" : "answer"
);
graph.addEdge("answer", "guardrail");
graph.addConditionalEdges("guardrail", (state) =>
state.needsEscalation ? "escalate" : END
);
graph.addEdge("escalate", END);
const app = graph.compile();
Wrap it with an API handler
Expose one endpoint that injects tenant metadata and writes an audit record. That keeps business logic out of your controller.
export async function askPolicy(question: string) {
const result = await app.invoke({
question,
jurisdiction: "EU",
role:
"operations",
retrievedContext:
[],
draftAnswer:
"",
finalAnswer:
"",
needsEscalation:
false,
});
return result.finalAnswer;
}
Production Considerations
- •
Auditability
Store every run with
question,jurisdiction, retrieved document IDs, model version, prompt version, and final output. In fintech audits, you need to prove what the system saw before it answered. - •
Data residency
Keep retrieval and inference inside the required region. If your policies are EU-only data or bank-confidential content , do not send them to a non-compliant region or unmanaged third-party endpoint.
- •
Guardrails
Add hard filters for prohibited advice categories like legal interpretation beyond policy text , sanctions decisions , or account-opening eligibility unless explicitly allowed. Use escalation instead of hallucinated certainty.
- •
Monitoring
Track retrieval hit rate , escalation rate , citation coverage , and answer rejection rate. A sudden drop in citation coverage usually means your index drifted or a new policy version was not ingested.
Common Pitfalls
- •
Letting the model answer without evidence
If retrieval returns no relevant documents , do not let the LLM “best effort” its way through. Route to escalation when context is missing.
- •
Skipping metadata filters
A global expense policy is not enough for a regulated business line. Filter by jurisdiction , entity , product , and effective date before you retrieve anything.
- •
Treating guardrails as a prompt-only problem
Prompt instructions are not controls. Put validation in code with conditional routing , output checks , and explicit fallback states so unsafe answers never reach users.
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