How to Build a policy Q&A Agent Using LangGraph in TypeScript for retail banking

By Cyprian AaronsUpdated 2026-04-21
policy-q-alanggraphtypescriptretail-bankingpolicy-qanda

A policy Q&A agent in retail banking answers customer or staff questions against approved policy documents, not free-form memory. That matters because the difference between “yes, you can waive this fee” and “no, that requires branch manager approval” is compliance risk, auditability, and customer trust.

Architecture

Build this agent with a narrow, auditable path from question to answer:

  • Input layer
    • Accepts a user question plus metadata like channel, country, customer segment, and authenticated role.
  • Policy retrieval layer
    • Pulls relevant policy snippets from a controlled store such as SharePoint exports, S3, or a vector index over approved documents.
  • Stateful LangGraph workflow
    • Uses StateGraph to route between retrieval, answer drafting, and fallback escalation.
  • Policy answer generator
    • Produces grounded answers only from retrieved policy text, with citations or document references.
  • Guardrail layer
    • Blocks unsupported topics, detects low-confidence retrieval, and escalates to human review when needed.
  • Audit logging layer
    • Stores the question, retrieved passages, model output, decision path, and timestamps for compliance review.

Implementation

1) Define the graph state and helpers

For retail banking, keep the state explicit. You want to know what was asked, what policy text was retrieved, whether the answer is safe to return, and why the graph took a given branch.

import { StateGraph, START, END } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";

type PolicyDoc = {
  id: string;
  title: string;
  text: string;
};

type GraphState = {
  question: string;
  jurisdiction: "US" | "UK" | "EU";
  role: "customer" | "agent" | "branch_staff";
  retrievedDocs: PolicyDoc[];
  answer?: string;
  confidence?: number;
  escalate?: boolean;
};

const llm = new ChatOpenAI({
  model: "gpt-4o-mini",
  temperature: 0,
});

async function retrievePolicies(question: string): Promise<PolicyDoc[]> {
  // Replace with your approved retrieval layer.
  return [
    {
      id: "fee-waiver-001",
      title: "Fee Waiver Policy",
      text: "Branch managers may waive overdraft fees once per quarter for customers in good standing.",
    },
  ];
}

2) Add nodes for retrieval, answer generation, and escalation

This pattern keeps the model on a short leash. Retrieval is deterministic enough to audit; generation is constrained to the retrieved policy text.

async function retrieveNode(state: GraphState): Promise<Partial<GraphState>> {
  const docs = await retrievePolicies(state.question);
  return { retrievedDocs: docs };
}

async function answerNode(state: GraphState): Promise<Partial<GraphState>> {
  const context = state.retrievedDocs
    .map((d) => `[${d.id}] ${d.title}: ${d.text}`)
    .join("\n");

  const prompt = `
You are a retail banking policy assistant.
Answer only using the provided policy context.
If the context does not support an answer, say you cannot confirm and recommend escalation.

Question: ${state.question}
Jurisdiction: ${state.jurisdiction}
Role: ${state.role}

Policy Context:
${context}
`;

  const res = await llm.invoke(prompt);
  return {
    answer: res.content.toString(),
    confidence: state.retrievedDocs.length > 0 ? 0.86 : 0.2,
    escalate: state.retrievedDocs.length === 0,
  };
}

async function escalateNode(state: GraphState): Promise<Partial<GraphState>> {
  return {
    answer:
      "I can’t confirm this from approved policy sources. Please route this to a licensed banking specialist or compliance team.",
    escalate: true,
    confidence: 0.0,
  };
}

3) Route based on retrieval quality and risk

In banking you should not rely on a single LLM completion. Use conditional routing so low-confidence or empty retrieval goes to escalation.

function routeAfterAnswer(state: GraphState): "end" | "escalate" {
  if (!state.retrievedDocs.length) return "escalate";
  if ((state.confidence ?? 0) < thresholdForJurisdiction(state.jurisdiction)) return "escalate";
  return "end";
}

function thresholdForJurisdiction(jurisdiction: GraphState["jurisdiction"]) {
  switch (jurisdiction) {
    case "EU":
      return 0.9;
    case "UK":
      return undefined ? (0.88 as number) : (0.88 as number);
    default:
      return undefined ? (0.85 as number) : (0.85 as number);
}

The clean way is to build the graph with addNode, addEdge, and addConditionalEdges, then compile it:

const graph = new StateGraph<GraphState>()
 .addNode("retrieve", retrieveNode)
 .addNode("answer", answerNode)
 .addNode("escalate", escalateNode)
 .addEdge(START, "retrieve")
 .addEdge("retrieve", "answer")
 .addConditionalEdges("answer", routeAfterAnswer, {
   end: END,
   escalate: "escalate",
 })
 .addEdge("escalate", END)
 .compile();

export async function runPolicyQa(question: string) {
 const result = await graph.invoke({
   question,
   jurisdiction: "UK",
   role: "agent",
   retrievedDocs: [],
 });
 return result.answer;
}

4) Add traceable output for audit

Retail banking teams will ask who answered what and on which source. Make your runtime persist state transitions and source IDs.

  • Log:
    • question
    • jurisdiction
    • role
    • retrieved document IDs
  • Store:
    • final answer
    • confidence score
    • escalation flag
    • model version
  • Keep logs immutable where possible:
    • append-only storage
    • retention aligned with internal records policy

Production Considerations

  • Compliance controls
    • Restrict answers to approved policies only.
    • Add jurisdiction-aware routing because UK/EU/US policies often differ on fees, disclosures, complaints handling, and consent language.
  • Auditability
    • Persist every graph run with source document IDs and timestamps.
    • If you cannot reconstruct why an answer was produced, it is not production-ready for regulated banking.
  • Data residency
    • Keep customer data and logs in-region where required.
    • Separate policy corpora by geography if your legal team requires local hosting or local processing boundaries.
  • Monitoring
    • Track escalation rate, unsupported-question rate, retrieval hit rate, and hallucination reports from QA sampling.
    • Alert when the agent starts answering outside its policy corpus or when confidence drops after a document update.

Common Pitfalls

  1. Letting the model answer without grounded context

    • Fix it by forcing retrieval before generation and escalating when no approved policy is found.
  2. Mixing jurisdictions in one prompt

    • A fee rule valid in one market may be wrong in another.
    • Partition documents by region and pass jurisdiction explicitly through state.
  3. Skipping audit metadata

    • If you do not store source IDs and node outputs, compliance cannot review decisions later.
    • Log graph state transitions alongside model responses.
  4. Using the same agent for customers and staff without role checks

    • Staff may need internal operational guidance that customers must never see.
    • Gate access by authenticated role before the graph runs.

A good retail banking policy Q&A agent is not clever; it is controlled. Build it so every answer can be traced back to an approved document and every uncertain case gets escalated instead of guessed.


Keep learning

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

Related Guides