How to Build a underwriting Agent Using AutoGen in TypeScript for banking
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.
| Item | Why it matters |
|---|---|
| Input payload hash | Proves which application was evaluated |
| Prompt version | Supports change control |
| Model name/version | Required for model governance |
| Policy version | Shows which rule set was applied |
| Final disposition | Needed 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
- •
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.
- •
Skipping schema validation
If you accept free-form text from the model, you will eventually ship malformed decisions into downstream systems. Parse with
zodor equivalent every time. - •
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
- •The complete AI Agents Roadmap — my full 8-step breakdown
- •Free: The AI Agent Starter Kit — PDF checklist + starter code
- •Work with me — I build AI for banks and insurance companies
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