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

By Cyprian AaronsUpdated 2026-04-21
underwritingllamaindextypescriptlending

An underwriting agent for lending takes borrower documents, extracts the right facts, checks them against policy, and produces a decision-ready summary for a human underwriter or an automated workflow. It matters because lending decisions need to be fast, consistent, auditable, and grounded in policy — not buried in ad hoc prompts and brittle scripts.

Architecture

  • Document ingestion layer

    • Pulls bank statements, payslips, tax returns, credit memos, and application forms from object storage or your loan origination system.
    • Normalizes PDFs and text into Document objects for LlamaIndex.
  • Policy knowledge base

    • Stores underwriting rules, product criteria, exception thresholds, and compliance notes.
    • Indexed with VectorStoreIndex so the agent can retrieve the exact policy passages it needs.
  • Case retrieval layer

    • Retrieves borrower-specific evidence from uploaded docs using VectorStoreIndex.asQueryEngine().
    • Keeps answers grounded in source text instead of model memory.
  • Decision orchestration

    • Uses an LLM-backed ReActAgent to compare borrower facts against policy and produce a structured recommendation.
    • Routes ambiguous cases to human review.
  • Audit and trace store

    • Persists retrieved chunks, model outputs, tool calls, and final recommendations.
    • Needed for compliance review, adverse action support, and internal model governance.

Implementation

1. Load policy docs and borrower files into LlamaIndex

Use separate indexes for policy and case documents. That gives you cleaner retrieval boundaries and better auditability.

import {
  Document,
  VectorStoreIndex,
  Settings,
} from "llamaindex";
import { OpenAI } from "@llamaindex/openai";

Settings.llm = new OpenAI({
  model: "gpt-4o-mini",
});

async function buildIndexes() {
  const policyDocs = [
    new Document({
      text: `
        Underwriting Policy:
        - Minimum FICO for unsecured personal loans: 680
        - Maximum DTI: 40%
        - Require proof of income for all applicants
        - Manual review required if bank statement cash deposits exceed 30% of monthly income
      `,
      metadata: { source: "policy-manual-v3" },
    }),
  ];

  const borrowerDocs = [
    new Document({
      text: `
        Applicant: Jane Doe
        Monthly income: $8,500
        Monthly debt payments: $2,900
        FICO: 702
        Cash deposits on bank statement: $3,100
      `,
      metadata: { source: "borrower-upload", applicantId: "app-123" },
    }),
  ];

  const policyIndex = await VectorStoreIndex.fromDocuments(policyDocs);
  const caseIndex = await VectorStoreIndex.fromDocuments(borrowerDocs);

  return { policyIndex, caseIndex };
}

2. Create query engines for policy lookup and case evidence

The underwriting agent should not “guess” policy. It should retrieve the relevant rule text and the relevant borrower evidence separately.

async function createRetrievers(policyIndex: VectorStoreIndex, caseIndex: VectorStoreIndex) {
  const policyQueryEngine = policyIndex.asQueryEngine({
    similarityTopK: 3,
    responseMode: "compact",
  });

  const caseQueryEngine = caseIndex.asQueryEngine({
    similarityTopK: 3,
    responseMode: "compact",
  });

  return { policyQueryEngine, caseQueryEngine };
}

3. Wire a ReAct agent around explicit tools

This is the pattern that works well in lending: one tool for policy retrieval, one tool for case retrieval. The agent then writes a decision memo with citations from both sources.

import {
  FunctionTool,
  ReActAgent,
} from "llamaindex";

async function buildUnderwritingAgent(
  policyQueryEngine: ReturnType<VectorStoreIndex["asQueryEngine"]>,
  caseQueryEngine: ReturnType<VectorStoreIndex["asQueryEngine"]>,
) {
  const policyTool = FunctionTool.from(async ({ question }: { question: string }) => {
    const result = await policyQueryEngine.query({ queryStr: question });
    return result.response;
  }, {
    name: "lookup_policy",
    description: "Retrieve underwriting policy language relevant to the applicant question.",
    parameters: {
      type: "object",
      properties: {
        question: { type: "string" },
      },
      required: ["question"],
    },
  });

  const caseTool = FunctionTool.from(async ({ question }: { question: string }) => {
    const result = await caseQueryEngine.query({ queryStr: question });
    return result.response;
  }, {
    name: "lookup_case",
    description: "Retrieve borrower-specific facts from submitted documents.",
    parameters: {
      type: "object",
      properties: {
        question: { type: "string" },
      },
      required: ["question"],
    },
  });

  return ReActAgent.fromTools([policyTool, caseTool], {
    llmOptions: {
      modelName: "gpt-4o-mini",
      temperature":0,
    },
    systemPrompt:
      "You are an underwriting assistant. Compare borrower facts to lending policy. Return a concise recommendation with reasons, exceptions, and missing documents. Never invent facts.",
  });
}

4. Ask for a decision memo with hard constraints

Force the output format so downstream systems can parse it. In lending, free-form prose is where bad decisions hide.

async function runUnderwrite(agent) {
const prompt = `
Evaluate this application:
1) Retrieve current underwriting rules for FICO, DTI, income verification, and cash deposit exceptions.
2) Retrieve applicant facts relevant to those rules.
3) Return JSON with:
   - decision ("approve" | "refer" | "decline")
   - reasons (array)
   - missing_items (array)
   - cited_policy (array)
   - cited_facts (array)

Do not make up any values.
`;

const response = await agent.chat({ message });
console.log(response.response);
}

A practical production pattern is to parse the final output into a schema before it reaches your loan origination system. If the JSON fails validation, route it to manual review instead of trying to “fix” it with another model call.

Production Considerations

  • Keep data residency explicit

    • Store borrower data in-region and pin your vector store plus LLM endpoints to approved jurisdictions.
    • For regulated lenders, document where embeddings are stored because they can still be considered derived customer data.
  • Log every decision path

    • Persist retrieved chunks, tool inputs/outputs, final recommendation, model version, prompt version, and timestamp.
    • This gives you auditability for internal controls and adverse action reviews.
  • Add guardrails before automation

    • Hard-stop on missing income verification or stale credit data.
  • Monitor drift by product segment

Common Pitfalls

  • Mixing policy docs with borrower docs in one index

This makes retrieval noisy and harder to audit. Keep separate indexes so you can show exactly which source drove which part of the decision.

  • Letting the model infer missing financial facts

If DTI or income is absent, the agent should return refer or missing_items, not estimate from context. In lending, invented numbers become compliance incidents fast.

  • Skipping structured output validation

Don’t trust raw text responses in production. Validate against a schema like decision/reasons/cited_policy/cited_facts before writing anything to your LOS or CRM.


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