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

By Cyprian AaronsUpdated 2026-04-21
policy-q-alanggraphtypescriptpaymentspolicy-qanda

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.

FieldWhy it matters
QuestionReconstruct user intent
RegionEnforce jurisdiction-specific rules
Policy IDsProve grounding source
Final answerAudit what was returned
Risk levelTrack escalations
Model versionSupport 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

  1. Letting the model answer without evidence

    If you don’t force retrieval first, you’ll get plausible but wrong answers. Fix it by making policyDocs mandatory before generation.

  2. 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.

  3. 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.

  4. 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

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