Skip to content

Mock-vs-real boundary

The demo’s job is to demonstrate the structure of an agentic credit broking journey. The structural parts are real. The parts that need a regulated counterparty (lender API, credit bureau, settlement, KYC) are mocked.

Knowing where the mock boundary is matters because it determines integration effort.

Every file outside lib/decision-engine.ts. Specifically:

  • Customer journey: every gate, every disclosure, every consent, every form, every chat turn. Real.
  • Installer surface: voice intake (real Web Speech API), fact extraction (real Claude call), customer link generation. Real.
  • Audit log: every event in the timeline, every disclosure timestamp, every consent record. Real.
  • Replay engine: every replay run is a real Anthropic API call. Real.
  • State machine: applyEvents, status transitions, terminal outcomes. Real.
  • Reconciliation: all three rules. Real.
  • Event protocol: <agent-event> tag parsing, direct-event dispatch. Real.
  • Cold-start recovery: seed encoding and hydration. Real.
  • System prompts: the agent’s instructions. Real.
  • Verbatim disclosure copy: lib/disclosures.ts. Real (production would have version-controlled CMS-published versions).

Concentrated in lib/decision-engine.ts:

const LENDERS: LenderProfile[] = [
{ name: "BNP Paribas", position: "Prime Priority 1", productCode: "IBC",
minAmount: 2000, maxAmount: 25000, minTerm: 36, maxTerm: 180,
baseAprPct: 9.9, amountAprStep: { per: 5000, bps: -25 } },
{ name: "This Bank", position: "Prime Priority 2", productCode: "IBC",
minAmount: 1000, maxAmount: 50000, minTerm: 24, maxTerm: 180,
baseAprPct: 11.4, amountAprStep: { per: 5000, bps: -20 } },
{ name: "Propensio", position: "Sub-Prime 1", productCode: "IBC",
minAmount: 1000, maxAmount: 20000, minTerm: 24, maxTerm: 120,
baseAprPct: 18.9, amountAprStep: { per: 5000, bps: -10 } },
];
export function runWaterfall({ caseState, fromIndex, scenario }: RunWaterfallInput): WaterfallResult {
// For each lender from fromIndex onwards:
// Apply rate card to the case (baseAprPct + amountAprStep tier)
// simulateLenderResponse decides: approve as requested, counter, or decline
// Returns sequential results until first non-declined or exhaustion
}

The lender adapters, their decisioning, their counter-offer logic, their rate cards are all in this one file. Integration with real lenders would replace this.

Several productisation concerns sit outside the demo entirely:

ConcernNotes
Real credit search APIs (Experian/TransUnion/Equifax)Out of scope. Soft-search and full-search would be lender-side concerns.
KYC / identity verificationOut of scope. Would be a step before pre-contract in production.
AML checksOut of scope. Sanctions screening, PEP checks.
Email sendingEmail opt-in is recorded in case state. No email actually sent.
SMS sendinggenerate_customer_link doesn’t actually trigger SMS. Production would.
Document e-signatureCustomer accepting an offer ends the demo. Production would route to a signing flow.
Settlement and fundingThe lender’s job in production.
Customer authenticationAnyone with the customer URL can resume. Production would have at least magic-link auth.
Audit page authenticationSame. Audit URLs are unauthenticated in the demo.
Persistent storageIn-memory + URL-borne seed only. Production would be a real database.

Three places to swap mock for real:

lib/decision-engine.ts. The runWaterfall function would take a panel of LenderAdapter interfaces (see Decision API for the full contract):

interface LenderAdapter {
name: string;
position: "Prime Priority 1" | "Prime Priority 2" | "Sub-Prime 1" | "Sub-Prime 2";
productCode: "IFC" | "BNPL" | "IBC";
decide(application: ApplicationPayload): Promise<WaterfallStepOutcome>;
}

Each lender adapter wraps your existing decision API. The waterfall calls them sequentially. Counter-offers come from the lender, not from local rate-card calculation.

See Lender panel integration and Decision API adapter.

lib/server-store.ts. The Map<sessionId, ServerSession> would be replaced with calls to a Redis or KV store:

async function ensureSession(sessionId: string): Promise<ServerSession> {
const cached = await kv.get(`session:${sessionId}`);
if (cached) return cached;
const session = emptySession(sessionId);
await kv.set(`session:${sessionId}`, session);
return session;
}

The seed pattern continues to work as a recovery mechanism even with persistent storage. See Production hardening.

lib/disclosures.ts. The hardcoded disclosure bodies would be served from a versioned CMS:

export async function getDisclosure(id: string): Promise<DisclosureContent> {
return await cms.fetchDisclosure(id, { version: "current" });
}

The audit log records the version that was presented, not just the id. Approval workflow (legal/compliance signoff) precedes a new version going live.

See Disclosure publishing.

A reference deployment for one broker, one product vertical (solar), one panel partnership: 8-12 weeks of focused work.

WeekFocus
1-2Branding, copy, disclosure text, domain
3-5Lender panel adapters (waterfall integration)
6-7CRM webhooks + vulnerability process integration
8-9Trust-gradient policy + broker-hosted disclosure pages
10-11Audit log integration with existing compliance tooling
12Pilot with 1-2 retailers, monitoring, iteration

This is a pilot, not a v1. A v1 would also wire in real database, KYC + AML, e-signature, lender funding webhooks, settlement reconciliation.

See Adoption path for brokers for the broker integration story.

The structural parts. The state machine, the reconciliation rules, the event protocol, the seed pattern, the audit and replay layers. These are the parts that would be tested, audited, and signed off once. After that, deploying for a new retailer or panel partnership is a configuration change, not a rewrite.

The bet is: the structural backbone is the hard part to get right. Once it’s right, productisation is wiring.