How to Build a loan approval Agent Using LlamaIndex in TypeScript for lending

By Cyprian AaronsUpdated 2026-04-21
loan-approvalllamaindextypescriptlending

A loan approval agent automates the first-pass decisioning flow for lending: it gathers applicant data, pulls policy and document context, applies underwriting rules, and returns a recommendation with an audit trail. For lenders, this matters because it reduces manual review load, shortens turnaround time, and makes decisions more consistent while keeping compliance and explainability in view.

Architecture

A production loan approval agent needs a small set of components that map cleanly to underwriting workflows:

  • Applicant intake layer

    • Accepts structured inputs like income, employment status, debt obligations, requested amount, and jurisdiction.
    • Normalizes data before any model call.
  • Policy retrieval layer

    • Pulls underwriting policy snippets, product rules, and compliance constraints from a controlled knowledge base.
    • In LlamaIndex terms, this is usually a VectorStoreIndex over internal policy documents.
  • Decision engine

    • Combines deterministic checks with LLM-assisted reasoning.
    • The LLM should not “invent” approvals; it should classify against policy and explain the outcome.
  • Audit and trace layer

    • Stores every input, retrieved policy chunk, tool call, and final recommendation.
    • This is non-negotiable in lending.
  • Guardrails layer

    • Blocks disallowed outputs like unsupported reasons for denial or use of protected attributes.
    • Enforces structured output so downstream systems can parse the result.
  • Human review handoff

    • Routes borderline cases to an underwriter when confidence is low or policy coverage is incomplete.

Implementation

1) Install dependencies and prepare your policy index

Use LlamaIndex TypeScript packages plus a vector store. For a real deployment, keep policies in a controlled corpus: underwriting guidelines, adverse action rules, product matrix docs, and regulatory notes.

npm install llamaindex
import {
  Document,
  VectorStoreIndex,
} from "llamaindex";

const policyDocs = [
  new Document({
    text: `
      Personal Loan Policy:
      - Minimum credit score: 680
      - Debt-to-income ratio must be below 40%
      - Employment must be verified
      - Do not use race, religion, gender, or marital status in decisions
      - If missing documentation exists, route to manual review
    `,
    metadata: { source: "policy/personal-loan.md", version: "2025.01" },
  }),
];

const index = await VectorStoreIndex.fromDocuments(policyDocs);

2) Define a strict decision schema

You want structured output because lending systems need deterministic downstream handling. Use Settings for the model and asQueryEngine() to ask the policy index for relevant guidance.

import {
  Settings,
  OpenAI,
} from "llamaindex";

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

type LoanDecision = {
  decision: "approve" | "deny" | "manual_review";
  reasons: string[];
  risk_flags: string[];
  policy_refs: string[];
};

type Applicant = {
  creditScore: number;
  dti: number;
  employmentVerified: boolean;
  loanAmount: number;
};

function buildPrompt(applicant: Applicant, policyContext: string) {
  return `
You are a lending decision assistant.
Use only the provided policy context.
Do not use protected attributes.
Return JSON with keys: decision, reasons, risk_flags, policy_refs.

Applicant:
${JSON.stringify(applicant, null, 2)}

Policy Context:
${policyContext}
`;
}

3) Query policy context and generate the recommendation

This is the core pattern. Retrieve the most relevant underwriting rules first, then ask the model to produce a constrained recommendation based on those rules.

import { QueryEngineTool } from "llamaindex";

async function decideLoan(applicant: Applicant): Promise<LoanDecision> {
  const queryEngine = index.asQueryEngine({
    similarityTopK: 3,
    responseMode: "compact",
  });

  const tool = QueryEngineTool.fromDefaults({
    queryEngine,
    name: "underwriting_policy_lookup",
    description: "Retrieves underwriting rules and compliance constraints",
  });

const query = `
Evaluate this applicant against personal loan policy:
creditScore=${applicant.creditScore}
dti=${applicant.dti}
employmentVerified=${applicant.employmentVerified}
loanAmount=${applicant.loanAmount}
`;

const response = await tool.call({ input: query });
const prompt = buildPrompt(applicant, response.toString());

const llmResponse = await Settings.llm.complete(prompt);
const raw = llmResponse.text.trim();

return JSON.parse(raw) as LoanDecision;
}

const applicant = {
  creditScore: 702,
  dti: 36,
  employmentVerified: true,
  loanAmount: 12000,
};

decideLoan(applicant).then(console.log);

4) Add deterministic pre-checks before the LLM

Do not send obviously ineligible applications into model reasoning. Hard rules should stay hard rules.

function precheck(applicant: Applicant): LoanDecision | null {
if (applicant.creditScore < 580) {
    return {
      decision: "deny",
      reasons: ["Credit score below minimum threshold"],
      risk_flags: ["hard_fail_credit_score"],
      policy_refs: ["policy/personal-loan.md#minimum-credit-score"],
    };
}

if (!applicant.employmentVerified) {
    return {
      decision: "manual_review",
      reasons: ["Employment not verified"],
      risk_flags: ["missing_verification"],
      policy_refs: ["policy/personal-loan.md#employment-verification"],
    };
}

return null;
}

Then compose both paths:

async function approveOrRoute(applicant: Applicant): Promise<LoanDecision> {
const hardStop = precheck(applicant);
if (hardStop) return hardStop;

return decideLoan(applicant);
}

Production Considerations

  • Keep model inputs minimal

    • Only pass fields needed for underwriting.
    • Exclude protected attributes entirely.
  • Persist full audit trails

    • Store applicant payloads, retrieved policy chunks, final JSON output, model version, prompt version, and timestamp.
    • This supports adverse action reviews and regulator questions.
  • Pin data residency

    • If you operate in multiple regions, keep applicant PII and retrieval indexes in-region.
    • Don’t ship sensitive borrower data across borders just to run inference.
  • Add monitoring around drift and overrides

Track:
- approval rate by product/channel
- manual review rate
- denial reason distribution
- underwriter override rate
- malformed JSON responses

High override rates usually mean your prompts are too loose or your policy corpus is stale.

Common Pitfalls

  1. Letting the LLM make unsupported decisions

    • Mistake: asking the model to “decide” without grounding it in current underwriting rules.
    • Fix: retrieve policy context with VectorStoreIndex.asQueryEngine() first and constrain output to those rules.
  2. Using free-form text instead of structured output

    • Mistake: parsing natural language denials in downstream systems.
    • Fix: require strict JSON with fixed keys like decision, reasons, and policy_refs.
  3. Skipping compliance controls

    • Mistake: including protected attributes or storing raw prompts without retention controls.
    • Fix:
      • remove sensitive fields before inference,
      • log every decision path,
      • keep region-specific storage boundaries,
      • review outputs against fair lending requirements regularly.
  4. Treating manual review as an afterthought

    • Mistake: forcing borderline cases into approve/deny only.
    • Fix:
      • define clear escalation thresholds,
      • route incomplete or ambiguous cases to underwriters,
      • capture reviewer feedback for future prompt and rule updates.

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