How to Build a underwriting Agent Using AutoGen in TypeScript for banking

By Cyprian AaronsUpdated 2026-04-21
underwritingautogentypescriptbanking

A underwriting agent in banking takes borrower data, policy rules, and supporting documents, then produces a credit recommendation with traceable reasoning. The point is not to replace credit officers; it is to remove manual triage, standardize policy checks, and keep every decision auditable for compliance.

Architecture

  • Input adapter
    • Normalizes application payloads from loan origination systems, PDFs, and CRM records into a single underwriting request.
  • Policy retrieval layer
    • Pulls bank-specific underwriting rules, KYC requirements, and product constraints from an approved source of truth.
  • AutoGen agent team
    • Uses one agent to analyze the case and another to validate policy/compliance before any recommendation is returned.
  • Decision engine
    • Converts the agent output into a strict underwriting outcome: approve, refer, or decline.
  • Audit logger
    • Stores prompts, tool calls, outputs, timestamps, and policy versions for model risk management and regulator review.
  • Human override workflow
    • Routes borderline cases to a credit analyst when confidence is low or the policy requires manual approval.

Implementation

1) Install AutoGen and define the underwriting input

For TypeScript, use the AutoGen AgentChat package. Keep the application payload typed so you can enforce banking controls before any LLM call.

npm install @microsoft/autogen-agentchat @microsoft/autogen-core zod
import { z } from "zod";

const UnderwritingRequestSchema = z.object({
  applicantId: z.string(),
  requestedAmount: z.number().positive(),
  annualIncome: z.number().nonnegative(),
  existingDebt: z.number().nonnegative(),
  ficoScore: z.number().int().min(300).max(850),
  country: z.string(),
  productType: z.enum(["personal_loan", "auto_loan", "small_business"]),
  documents: z.array(z.string()).default([]),
});

type UnderwritingRequest = z.infer<typeof UnderwritingRequestSchema>;

const request: UnderwritingRequest = UnderwritingRequestSchema.parse({
  applicantId: "APP-10291",
  requestedAmount: 25000,
  annualIncome: 98000,
  existingDebt: 12000,
  ficoScore: 712,
  country: "US",
  productType: "personal_loan",
  documents: ["paystub.pdf", "bank_statement.pdf"],
});

This validation step matters. In banking, you do not want free-form inputs going straight into an agent because bad data creates bad decisions and bad audit trails.

2) Create the underwriting agent with AutoGen

Use AssistantAgent for analysis and UserProxyAgent as the controlled execution boundary. The assistant should never directly make a final booking decision; it should produce a recommendation that your application can validate.

import { AssistantAgent, UserProxyAgent } from "@microsoft/autogen-agentchat";
import { OpenAIChatCompletionClient } from "@microsoft/autogen-ext/openai";

const modelClient = new OpenAIChatCompletionClient({
  model: "gpt-4o-mini",
});

const underwriter = new AssistantAgent({
  name: "underwriter",
  modelClient,
  systemMessage: `
You are a banking underwriting analyst.
Follow bank policy exactly.
Do not invent missing facts.
Return JSON only with:
- decision: approve | refer | decline
- reasons: string[]
- riskFlags: string[]
- confidence: number
- policyChecks: string[]
`,
});

const reviewer = new UserProxyAgent({
  name: "credit_policy_reviewer",
});

The key pattern here is that the model output is constrained to structured JSON. That makes it much easier to enforce deterministic post-processing in a regulated environment.

3) Run the agent conversation and parse the result

The simplest production pattern is one analysis turn followed by strict validation. If the output fails schema validation, route it to manual review.

const underwritingPrompt = `
Evaluate this loan application using standard bank policy:

Applicant ID: ${request.applicantId}
Requested Amount: ${request.requestedAmount}
Annual Income: ${request.annualIncome}
Existing Debt: ${request.existingDebt}
FICO Score: ${request.ficoScore}
Country of Residence: ${request.country}
Product Type: ${request.productType}

Rules:
- Debt-to-income above threshold requires refer or decline
- FICO below threshold requires refer or decline
- Do not approve if compliance flags exist
`;

async function main() {
  const result = await underwriter.run([{ role: "user", content: underwritingPrompt }]);

  const content = result.messages.at(-1)?.content;
  if (typeof content !== "string") {
    throw new Error("Unexpected agent response format");
    }

  const parsed = JSON.parse(content);

  const OutputSchema = z.object({
    decision: z.enum(["approve", "refer", "decline"]),
    reasons: z.array(z.string()),
    riskFlags: z.array(z.string()),
    confidence: z.number().min(0).max(1),
    policyChecks: z.array(z.string()),
  });

  const decision = OutputSchema.parse(parsed);

  if (decision.decision === "approve" && decision.confidence < 0.8) {
    return { finalDecision": "refer", ...decision };
  }

  return { finalDecision": decision.decision, ...decision };
}

main().then(console.log).catch(console.error);

Notice the control point after parsing. Banking systems should never trust raw model output; always map it through a schema and business rules before returning anything downstream.

4) Add audit logging and human referral

Store enough context to reproduce the decision later. That includes input version, prompt version, model version, output JSON, and who overrode what.

ItemWhy it matters
Input payload hashProves which application was evaluated
Prompt versionSupports change control
Model name/versionRequired for model governance
Policy versionShows which rule set was applied
Final dispositionNeeded for audit and reporting

A practical pattern is to write an immutable event after each run:

type AuditEvent = {
  applicantId: string;
  timestampUtc: string;
  modelName: string;
  promptVersion": string;
};

function writeAuditEvent(eventData) {
  
}

In production, send this to your SIEM or append-only storage rather than a mutable database table.

Production Considerations

  • Data residency

    Keep borrower PII in-region. If your bank operates in multiple jurisdictions, pin inference endpoints to approved regions and block cross-border transfers unless legal has signed off.

  • Compliance controls

    Add pre-processing for sanctions screening, KYC status, and adverse action reasons. The agent should recommend; your rules engine should decide whether regulatory conditions are met.

  • Monitoring

    Track approval rate drift, referral rate drift, schema parse failures, latency p95, and override frequency by analyst. Spikes usually mean prompt regression or upstream data quality issues.

  • Guardrails

    Redact sensitive fields before prompting when they are not needed for the decision. Never send full account numbers or unmasked identifiers unless there is a documented business reason.

Common Pitfalls

  1. Letting the LLM make the final credit decision

    Keep final authority in deterministic code. The agent should produce structured analysis; your policy engine should convert that into approve/refer/decline.

  2. Skipping schema validation

    If you accept free-form text from the model, you will eventually ship malformed decisions into downstream systems. Parse with zod or equivalent every time.

  3. Ignoring policy versioning

    A credit decision without policy version metadata is weak evidence during audit review. Store the exact rule set used for each application so you can reproduce outcomes later.


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