How to Build a policy Q&A Agent Using LangGraph in TypeScript for payments
A policy Q&A agent for payments answers questions like “Can we refund this card charge?”, “Is this merchant category allowed?”, or “What’s our chargeback handling policy for EU customers?” It matters because payment teams need fast, consistent answers without exposing sensitive data or letting the agent invent policy that doesn’t exist.
Architecture
Build this agent with a narrow, auditable graph. For payments, you want deterministic retrieval and explicit refusal paths, not a free-form chatbot.
- •User input node
- •Accepts the question plus minimal context: region, product line, merchant type, and customer tier.
- •Policy retrieval node
- •Pulls the relevant policy sections from a controlled source like Postgres, SharePoint export, or a versioned document store.
- •Policy grounding node
- •Converts retrieved snippets into an answer with citations and a confidence flag.
- •Compliance guardrail node
- •Blocks responses that expose restricted operational details or contradict payment compliance rules.
- •Audit logging node
- •Stores question, retrieved policy IDs, answer, model version, and decision path for later review.
- •Escalation node
- •Routes ambiguous or high-risk questions to a human queue when the policy is unclear or jurisdiction-specific.
Implementation
1) Define the graph state and nodes
Use StateGraph from LangGraph and keep the state explicit. For payments, the state should carry both the user question and the evidence used to answer it.
import { StateGraph, START, END } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
type PolicyDoc = {
id: string;
title: string;
text: string;
};
type AgentState = {
question: string;
region?: string;
policyDocs: PolicyDoc[];
draftAnswer?: string;
finalAnswer?: string;
risk?: "low" | "medium" | "high";
};
const llm = new ChatOpenAI({
model: "gpt-4o-mini",
temperature: 0,
});
async function retrievePolicies(state: AgentState): Promise<Partial<AgentState>> {
// Replace with your real retrieval layer
const docs: PolicyDoc[] = [
{
id: "refund-policy-us-001",
title: "US Refund Policy",
text: "Refunds are allowed within 30 days for eligible card-present transactions.",
},
{
id: "chargeback-policy-eu-014",
title: "EU Chargeback Policy",
text: "Chargeback disputes must be escalated to operations within 2 business days.",
},
];
return { policyDocs: docs };
}
async function draftAnswer(state: AgentState): Promise<Partial<AgentState>> {
const context = state.policyDocs
.map((d) => `[${d.id}] ${d.title}: ${d.text}`)
.join("\n");
const prompt = `
You are a payments policy assistant.
Answer only using the provided policy excerpts.
If the excerpts do not contain enough information, say you cannot determine it.
Question: ${state.question}
Region: ${state.region ?? "unknown"}
Policy excerpts:
${context}
`;
const response = await llm.invoke(prompt);
return { draftAnswer: response.content.toString() };
}
async function guardrail(state: AgentState): Promise<Partial<AgentState>> {
const riskyTerms = ["password", "card number", "cvv", "secret key"];
const answer = state.draftAnswer ?? "";
const hasRiskyContent = riskyTerms.some((term) =>
answer.toLowerCase().includes(term)
);
if (hasRiskyContent) {
return {
finalAnswer:
"I can’t provide that detail. Please route this to compliance or support.",
risk: "high",
};
}
return {
finalAnswer: answer,
risk: "low",
};
}
2) Wire the nodes into a LangGraph workflow
This is the core pattern. Keep the flow simple: retrieve policies first, generate only from those policies, then apply guardrails before returning anything.
const graph = new StateGraph<AgentState>()
.addNode("retrievePolicies", retrievePolicies)
.addNode("draftAnswer", draftAnswer)
.addNode("guardrail", guardrail)
.addEdge(START, "retrievePolicies")
.addEdge("retrievePolicies", "draftAnswer")
.addEdge("draftAnswer", "guardrail")
.addEdge("guardrail", END)
.compile();
async function main() {
const result = await graph.invoke({
question: "Can we refund a card payment after 45 days?",
region: "US",
policyDocs: [],
});
console.log(result.finalAnswer);
}
main();
3) Add routing for escalation on ambiguous cases
For payments, not every question should get an automatic answer. If the policy is incomplete or jurisdiction-specific, route to human review using addConditionalEdges.
function routeByRisk(state: AgentState): string {
if (!state.draftAnswer) return "escalate";
const uncertainPhrases = [
"cannot determine",
"not enough information",
"depends on jurisdiction",
"escalate",
];
if (uncertainPhrases.some((p) => state.draftAnswer!.toLowerCase().includes(p))) {
return "escalate";
}
return "guardrail";
}
async function escalate(state: AgentState): Promise<Partial<AgentState>> {
return {
finalAnswer:
`This needs human review. Question logged for compliance escalation.`,
risk: "medium",
};
}
const routedGraph = new StateGraph<AgentState>()
.addNode("retrievePolicies", retrievePolicies)
.addNode("draftAnswer", draftAnswer)
.addNode("guardrail", guardrail)
.addNode("escalate", escalate)
.addEdge(START, "retrievePolicies")
.addEdge("retrievePolicies", "draftAnswer")
.addConditionalEdges("draftAnswer", routeByRisk, {
guardrail: "guardrail",
escalate: "escalate",
})
.addEdge("guardrail", END)
.addEdge("escalate", END)
.compile();
4) Log everything you need for audit
Payments teams will ask who asked what, which policy was used, and whether the agent gave an approved answer. Persist that metadata outside the graph in your app layer.
| Field | Why it matters |
|---|---|
| Question | Reconstruct user intent |
| Region | Enforce jurisdiction-specific rules |
| Policy IDs | Prove grounding source |
| Final answer | Audit what was returned |
| Risk level | Track escalations |
| Model version | Support incident review |
Production Considerations
- •Deploy in-region
Use a data plane that respects residency requirements. If your payment policies include EU customer handling rules, keep retrieval and inference in-region where required.
- •Log immutable traces
Store graph inputs, retrieved document IDs, output text, and routing decisions in append-only logs. That gives compliance and internal audit a defensible trail.
- •Add hard refusals for regulated content
Block requests involving PCI data, secrets, authentication bypasses, fraud evasion, or internal control gaps. The agent should never explain how to defeat payment controls.
- •Monitor refusal rate and escalation rate
A spike in escalations usually means your retrieval corpus is stale or your prompts are too vague. A spike in confident answers on low-evidence questions is worse; that means hallucination risk.
Common Pitfalls
- •
Letting the model answer without evidence
If you don’t force retrieval first, you’ll get plausible but wrong answers. Fix it by making
policyDocsmandatory before generation. - •
Mixing public support docs with internal controls
Payment policy often has layers: customer-facing help center content and internal operational rules. Keep them separate so the agent doesn’t leak restricted procedures.
- •
Ignoring jurisdiction
Refund windows, dispute handling, SCA flows, and retention rules vary by region. Always pass region into state and route uncertain cases to humans when local policy isn’t explicit.
- •
Skipping audit metadata
If you can’t show which policy version produced an answer, compliance will treat the agent as untrusted. Log policy IDs and model version on every run.
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