API routes
The demo’s server surface is small. Eight routes. Every one runs on Vercel’s Node.js function runtime.
Sessions
Section titled “Sessions”POST /api/session
Section titled “POST /api/session”Create a new session. No body required.
Response:
{ sessionId: string; case: CaseState; // empty case}Used by the installer page on first load.
GET /api/session/[sessionId]?seed=...
Section titled “GET /api/session/[sessionId]?seed=...”Read session state. Optional seed query parameter for cold-start hydration.
Response:
{ sessionId: string; case: CaseState; installerMessages: ChatMessage[]; customerMessages: ChatMessage[];}Calls reconciliation read-only at the top.
POST /api/session/[sessionId]/seed
Section titled “POST /api/session/[sessionId]/seed”Hydrate a session from a seed.
Body:
{ seed: string }Response:
{ case: CaseState; hydrated: true }Used by the customer page when it lands on a cold instance with a seed in the URL.
POST /api/chat/installer
Section titled “POST /api/chat/installer”Installer-side chat turn.
Body:
{ sessionId: string; userMessage?: string; isFirstTurn?: boolean; seed?: string; directEvents?: AgentEvent[];}Response:
{ case: CaseState; assistantMessage: ChatMessage | null; events: AgentEvent[]; // events the model emitted installerMessages: ChatMessage[]; seed: string; // refreshed seed for next call}POST /api/chat/customer
Section titled “POST /api/chat/customer”Customer-side chat turn. The main route. Runs on runtime: "nodejs" with maxDuration: 60 seconds. A single auto-retry (with 800ms backoff) absorbs transient Anthropic failures.
Body:
{ sessionId: string; userMessage?: string; isFirstTurn?: boolean; demoScenario?: "auto" | "clean" | "counter" | "decline"; seed?: string; /** * Loose-typed in the route handler (not narrowed to AgentEvent at the * boundary), then routed through applyEvents which validates per-case. */ directEvents?: Array<{ type: string; data?: Record<string, unknown> }>;}Response:
{ case: CaseState; assistantMessage: ChatMessage | null; // null if model emitted only events events: AgentEvent[]; customerMessages: ChatMessage[]; seed: string;}The route’s full pipeline:
- Hydrate from seed if provided
- Apply direct events
- Pre-model reconciliation (read-only)
- Build system notes for direct-event-driven nudges to the model
- Append user message if provided
- Build model history (filtered, collapsed, capped at user turn)
- Call Anthropic
- Parse model events, apply them
- Post-model reconciliation (submit allowed)
- Append assistant message (if non-empty)
- Build refreshed seed
- Return
See app/api/chat/customer/route.ts for the implementation.
GET /api/audit/[sessionId]?seed=...
Section titled “GET /api/audit/[sessionId]?seed=...”Build the full audit payload. Optional seed.
Response:
AuditPayload { sessionId: string; case: CaseState; timeline: AuditTimelineEntry[]; compliance: ComplianceSummary; customerMessages: ChatMessage[];}Calls reconciliation read-only.
See Audit & replay.
POST /api/audit/[sessionId]/replay
Section titled “POST /api/audit/[sessionId]/replay”Run statistical replay across the case’s disclosures. Runs on runtime: "nodejs" with maxDuration: 120 seconds (longer than the chat route because it makes up to n model calls per disclosure sequentially).
Body:
{ n?: number; // default 5, capped at 20 seed?: string;}Returns 400 if the case has no recorded disclosures yet (“complete the journey first”). The route does not auto-submit; it always reads case state and replays against the recorded disclosure list.
Response:
ReplaySummary { sessionId: string; disclosures: ReplayResult[]; overallPassRate: number; totalRuns: number;}Each run is a real Anthropic API call. Charge applies; see Vercel deployment for cost notes.
Utility
Section titled “Utility”POST /api/address-lookup
Section titled “POST /api/address-lookup”Mock UK postcode lookup. Returns a list of addresses for a given postcode.
Body:
{ postcode: string }Response:
{ postcode: string; results: Array<{ addressLine1: string; addressLine2?: string; town: string; county?: string; }>;}Mocked. Returns plausible addresses for any postcode in lib/address-lookup.ts’s AREA_MAP. In production this would call a real address-lookup API (e.g. Loqate, Ideal Postcodes).
Authentication
Section titled “Authentication”None. All routes are unauthenticated in the demo. See Production hardening for the auth requirements.
Rate limiting
Section titled “Rate limiting”None. The demo doesn’t rate-limit anything. The replay route is the most expensive (each run is a model call); in production it should have at least IP-based rate limiting.
Errors
Section titled “Errors”All routes return JSON for both success and error responses. Errors come back with a non-2xx status code:
{ error: "human-readable error message", debug?: { ... } }The chat route includes a debug field with historyLength and historyRoles when an Anthropic call fails after retry. Useful for diagnosing the empty-turn / wrong-role-end class of bug. See Empty-turn protection.
Adding new routes
Section titled “Adding new routes”The pattern for a new state-reading route:
import { hydrateFromSeed, ensureSession } from "@/lib/server-store";import { reconcileSession } from "@/lib/reconcile";
export async function GET(req: Request, ctx) { const { sessionId } = await ctx.params; const seed = new URL(req.url).searchParams.get("seed"); if (seed) hydrateFromSeed(sessionId, seed);
reconcileSession(sessionId, { allowSubmit: false });
const session = ensureSession(sessionId); return NextResponse.json({ /* your shape */ });}Skip the hydrateFromSeed step and the route will work on warm Vercel instances and break on cold ones.