How to Build a underwriting Agent Using LangGraph in TypeScript for lending

By Cyprian AaronsUpdated 2026-04-21
underwritinglanggraphtypescriptlending

An underwriting agent for lending takes an application, pulls the right borrower and loan data, evaluates policy rules, and produces a decision package with reasons. For lending teams, this matters because you need consistent decisions, auditability, and fast turnaround without pushing every case through manual review.

Architecture

  • Input normalization layer

    • Converts raw application payloads into a typed underwriting state.
    • Validates required fields like income, DTI, loan amount, jurisdiction, and consent flags.
  • Policy evaluation node

    • Applies deterministic lending rules before any model call.
    • Handles hard stops like missing consent, restricted geographies, or debt-to-income thresholds.
  • Risk analysis node

    • Uses an LLM or scoring service to summarize risk factors from structured data.
    • Produces explainable outputs: income stability, utilization, delinquency signals, and exceptions.
  • Decision orchestration graph

    • Routes cases through approve, decline, or manual review paths.
    • Keeps the logic explicit so compliance can inspect the flow.
  • Audit and trace sink

    • Stores inputs, intermediate decisions, and final recommendation.
    • Required for model governance, adverse action review, and regulator requests.
  • Human review handoff

    • Escalates borderline cases to an underwriter with a complete evidence bundle.
    • Prevents the agent from making unsupported exceptions.

Implementation

1) Define the underwriting state and graph nodes

Use a typed state so every node reads and writes predictable fields. In lending systems, this is where you enforce data shape before policy logic runs.

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

type Decision = "approve" | "decline" | "manual_review";

type UnderwritingState = {
  applicationId: string;
  applicant: {
    name: string;
    annualIncome: number;
    debtToIncome: number;
    creditScore: number;
    jurisdiction: string;
    consentGiven: boolean;
  };
  loanAmount: number;
  productType: "personal_loan" | "auto_loan" | "small_business";
  policyFlags: string[];
  riskSummary?: string;
  decision?: Decision;
  reasonCodes?: string[];
};

const UnderwritingAnnotation = Annotation.Root({
  applicationId: Annotation<string>(),
  applicant: Annotation<UnderwritingState["applicant"]>(),
  loanAmount: Annotation<number>(),
  productType: Annotation<UnderwritingState["productType"]>(),
  policyFlags: Annotation<string[]>(),
  riskSummary: Annotation<string | undefined>(),
  decision: Annotation<Decision | undefined>(),
  reasonCodes: Annotation<string[] | undefined>(),
});

2) Add deterministic policy checks first

For lending, hard rules should happen before any model inference. That keeps compliance logic stable and makes declines explainable.

const policyCheck = async (state: typeof UnderwritingAnnotation.State) => {
  const flags: string[] = [];

  if (!state.applicant.consentGiven) flags.push("NO_CONSENT");
  if (state.applicant.debtToIncome > 0.45) flags.push("DTI_TOO_HIGH");
  if (state.applicant.creditScore < 620) flags.push("CREDIT_SCORE_BELOW_MIN");
  
  if (["restricted_state", "sanctioned_region"].includes(state.applicant.jurisdiction)) {
    flags.push("JURISDICTION_RESTRICTED");
  }

  return { policyFlags: flags };
};

3) Generate a risk summary and route the case

The graph should only call the LLM when the case passes basic eligibility checks. Use the summary to support a decision package; do not let it override hard policy rules.

const riskAnalysis = async (state: typeof UnderwritingAnnotation.State) => {
  const summary =
    `Applicant ${state.applicant.name} has income ${state.applicant.annualIncome}, ` +
    `DTI ${state.applicant.debtToIncome}, credit score ${state.applicant.creditScore}. ` +
    `Loan amount requested is ${state.loanAmount} for ${state.productType}.`;

  return {
    riskSummary:
      state.policyFlags.length === 0
        ? `${summary} Preliminary risk looks acceptable pending affordability review.`
        : `${summary} Policy exceptions present: ${state.policyFlags.join(", ")}.`,
  };
};

const decide = async (state: typeof UnderwritingAnnotation.State) => {
  if (state.policyFlags.includes("NO_CONSENT") || state.policyFlags.includes("JURISDICTION_RESTRICTED")) {
    return { decision: "decline" as const, reasonCodes: state.policyFlags };
    }

  if (state.policyFlags.length > 0) {
    return { decision: "manual_review" as const, reasonCodes: state.policyFlags };
  }

  if (state.applicant.creditScore >= 700 && state.applicant.debtToIncome <= 0.35) {
    return { decision: "approve" as const, reasonCodes: ["MEETS_POLICY"] };
}

return { decision: "manual_review" as const, reasonCodes: ["BORDERLINE_RISK"] };
};

4) Wire the graph with LangGraph’s actual API

StateGraph, addNode, addEdge, addConditionalEdges, compile() are the core pieces here. This is the pattern you want in production because it keeps routing explicit and testable.

const routeByDecision = (state: typeof UnderwritingAnnotation.State) => state.decision ?? "manual_review";

const graph = new StateGraph(UnderwritingAnnotation)
.addNode("policyCheck", policyCheck)
.addNode("riskAnalysis", riskAnalysis)
.addNode("decide", decide)
.addEdge(START, "policyCheck")
.addEdge("policyCheck", "riskAnalysis")
.addEdge("riskAnalysis", "decide")
.addConditionalEdges("decide", routeByDecision, {
   approve: END,
   decline: END,
   manual_review: END,
});

export const underwritingAgent = graph.compile();

async function run() {
   const result = await underwritingAgent.invoke({
      applicationId: "app_123",
      applicant: {
         name: "Amina Patel",
         annualIncome: 95000,
         debtToIncome: null as unknown as number,
         creditScore,
         jurisdiction,
         consentGiven,
      },
      loanAmount,
      productType,
      policyFlags,
   });

   console.log(result);
}

Production Considerations

  • Keep policy rules outside the model

  • Put regulatory thresholds in versioned config or code-reviewed rule modules.

  • This gives you stable behavior for adverse action notices and internal audits.

  • Log full decision traces

  • Persist input payloads, policy flags, routing decisions, and final output.

  • You need this for model governance reviews and lender exam requests.

  • Respect data residency

  • Keep borrower PII in-region if your lending program operates under local storage rules.

  • If you call external models or services, make sure they are approved for that jurisdiction.

  • Add human override controls

  • Any manual review path should preserve why the case was escalated.

  • Underwriters need evidence bundles they can trust without reconstructing the entire chain.

Common Pitfalls

  1. Letting the LLM make final credit decisions

    • Don’t do that.
    • Use deterministic rules for approval/decline gates and reserve model output for summarization or exception detection.
  2. Skipping audit fields in the graph state

    • If you don’t store reason codes and intermediate flags, you will fail compliance reviews later.
    • Keep policyFlags, reasonCodes, and trace metadata in state from day one.
  3. Mixing eligibility checks with affordability analysis

    • Eligibility is hard policy; affordability is risk analysis.
    • Separate them into different nodes so your routing stays explainable and your team can update one without breaking the other.

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