Confidential Insurance Platform
Live in productionAn AI chatbot for insurance discovery, rebuilt around explicit state, typed contracts, and modular workflow boundaries.
- Role
- Contributor — Microsoft Entra ID, AI chatbot, conversation-engine architecture
- When
- 2025
- TypeScript
- React
- Vite
- Azure
- Valibot
- i18next
- Storybook
- Node.js
What It Is
A confidential insurance platform uses an AI chatbot to guide customers through coverage discovery, policy linking or upload, property or vehicle intake, coverage review, quoting, and an advisor handoff when a licensed human should take over.
I joined in two distinct phases: first to ship authentication immediately before the initial release, and then to implement and re-architect the chatbot's frontend engine from a launch-oriented orchestrator into explicit, testable application boundaries.
The second engagement was the refactoring of a complex system, not a greenfield rewrite: the new design had to preserve the product's guided flows while making the next workflow cheaper and safer to add.
Phase One: Critical Release Work in One Week
I was asked (with a high level of urgency) to help with a critical release one week before go-live. I happily took on the short-term, weekend engagement and delivered Microsoft Entra ID authentication on Azure. Having never used Azure before, I quickly learned the platform, its authentication documentation, and its SDKs. I implemented the webflow, integrated it with the existing React frontend, and ensured that it worked seamlessly with the backend services in time for the release.
The useful engineering lesson was scope control. Identity is a cross-cutting concern, but its integration points are finite: session establishment, client state, protected API access, and recovery after an interrupted journey. Treating those as a bounded interface kept an unfamiliar platform from becoming a schedule risk for me. Any natural "squirreling" moments I had, I just wrote down as a follow-up task for the next phase, which brings us to the second engagement.
The Scaling Constraint
The production frontend had successfully carried the launch, but its central useChat hook
had become the owner of too many policies. At 5,194 lines, it coordinated 54 state cells, 38 refs, and 91 callbacks across streamed conversation, deterministic guided steps, browser and authenticated persistence, authentication recovery, policy-import polling, property enrichment, quote workflows, wallet state, and advisor handoff.
Deadline pressure can explain how a system reaches this shape, but that's also what makes the case for specification-driven development, of which I am a huge proponent. Core workflow behavior, state transitions, and side effects need explicit contracts; here, they had accumulated across one orchestrator.
A developer (myself) adding one question or one branch had to reason about parallel React state and ref synchronization, active-widget ownership, resume logic, stream lifecycle, and multiple places that could advance the journey. I was stressed... kidding. It was frustrating, though.
flowchart TB
UI["Chat UI"] --> ORCH["useChat<br/>central orchestrator"]
ORCH --> MSG["Transcript + streaming state"]
ORCH --> FLOW["Guided-step callbacks<br/>& widget activation"]
ORCH --> PERSIST["Session restore<br/>& durable persistence"]
ORCH --> INTEGRATIONS["Policy, property, gap, quote<br/>& handoff workflows"]
ORCH --> REFS["Mirrored refs<br/>for async callbacks"]
ORCH --> PROGRESS["Stepper projection"]
The issue here was not that the flows were incorrect. The issue was cohesion: transition rules were distributed among callbacks, UI directives, widget selection, and resumption logic. The more conversation paths the product gained, the more context a maintainer had to load before changing one of them safely. Each state value wasn't part of a cohesive object; instead, it was a series of explicit useState calls, which made reasoning about the system somewhat difficult. I couldn't get a full grasp of what the exact state was at any given time.
The Refactor: Make State the Source of Truth
The replacement design I made moves the system from a callback-led orchestrator to a composed, reducer-oriented conversation engine. Its center is a canonical ChatState object containing the durable domain data: status, current step, verification, intake, and wallet. resolve() derives the next step from that state rather than asking individual callbacks to assign the next destination.
That creates a decisive change in the programming model: a user answer updates the domain state; the state machine determines the next interaction.
flowchart TB
P["AppProvider<br/>composition root"] --> CHAT["useChat<br/>state + dispatch boundary"]
P --> CONV["useConversation<br/>transcript + freeform routing"]
P --> SERVICES["useServices<br/>live / mock registry"]
P --> STEPPER["useStepper<br/>progress projection"]
CHAT --> STATE["ChatState<br/>canonical domain state<br/>resolve() derives next step"]
CHAT --> FACTORY["StepHandlerFactory"]
FACTORY --> HANDLER["StepHandler<br/>validate · store · transition"]
HANDLER -. "selects by step + conditions" .-> REGISTRY["TOOL_REGISTRY<br/>typed descriptors"]
REGISTRY -. "descriptor" .-> PROMPT["AssistantMessage<br/>prompt + tool"]
PROMPT --> FEED["ChatFeed"]
FEED --> TOOL["Tool component"]
TOOL -->|"structured payload"| DISPATCH["dispatch('store')"]
DISPATCH --> HANDLER
HANDLER -->|"new ChatState"| STATE
Canonical Domain State
ChatState separates durable insurance data from presentation concerns. Intake, verification,
wallet, and status live in one domain-oriented object; the transcript lives in the conversation
layer; the stepper is a pure projection. This removes the ambiguity of a system where a
message, an active widget ID, a callback-local value, and a synchronized ref can each imply a
different current state.
Nested policy intake also makes branching legible. A homeowners path, for example, is selected
from intake data and resolves through a named step union instead of through a chain of UI
callbacks. The result is inspectable state, repeatable transitions, and a natural boundary for
snapshot tests.
Descriptors Replace UI Branching
Tools are registered as descriptors, not embedded as conditional branches in a page-level
orchestrator. A descriptor carries its ID, display metadata, translated options, optional
availability predicate, component factory, and Valibot input/output contract. For a given
step, StepHandler.load() selects the first descriptor whose conditions(state) pass.
flowchart LR
STEP["Resolved step"] --> REG["TOOL_REGISTRY[step]"]
REG --> CHECK{"conditions(state)"}
CHECK -->|"matches"| DESC["ChatTool descriptor"]
CHECK -->|"does not match"| NEXT["next descriptor"]
DESC --> MESSAGE["Localized assistant message"]
MESSAGE --> COMPONENT["Tool component"]
COMPONENT --> PAYLOAD["Typed payload"]
PAYLOAD --> HANDLER["StepHandler validation<br/>and state update"]
This is the extensibility point. Adding a ZIP tool, an existing-policy choice, or a jurisdiction-specific form becomes a descriptor, a handler, and a registry entry. It does not require modifying a broad control-flow hook. The distinction between a descriptor and its React component also keeps selection rules, rendering, and validation from becoming one concern.
Transition and Rendering Boundaries
StepHandlerFactory resolves the handler for the current step. The handler validates a
structured answer, stores it in canonical state, and returns the next immutable ChatState.
useStep observes that state and appends the localized prompt and selected tool to the
conversation. ChatFeed renders visible messages and invokes the descriptor component;
useStepper derives progress without owning any workflow state.
One guided turn therefore has a single path through the system:
sequenceDiagram
participant U as Customer
participant T as Tool component
participant C as useConversation
participant H as StepHandler
participant S as ChatState
participant F as ChatFeed / useStep
U->>T: Select option or submit a field
T->>C: Append user message with payload
Note over C: Structured payloads bypass freeform transport
T->>H: dispatch('store', payload)
H->>H: Validate and store domain data
H->>S: Return immutable state copy
S->>S: resolve() selects the next step
S-->>F: State reference changes
F->>C: Append localized prompt + selected descriptor
C-->>F: Render the next tool
The AI Boundary: Freeform Input Without Workflow Ambiguity
The chatbot supports both structured input and freeform text. They intentionally take
different paths. A structured tool payload updates the state machine directly. Freeform text is
routed through useFreeformMessageHandler and LlmService, which isolates model request,
response generation, mock, and error behavior from the UI and state-transition layers.
At this stage, the model-backed response capability is deliberately limited. The deterministic guided flow owns the high-confidence insurance questions and form-like inputs; freeform input can call the model service and return generated assistant text, a state patch, or both. This lets customers ask in their own words without allowing a generated response to become the owner of the insurance workflow.
Before a conversation leaves the client, sanitizeConversation strips executable descriptor
internals. The transport receives only a tool's ID, description, step, and materialized
options—not React components, predicates, or schemas. Non-2xx responses and malformed JSON
are normalized into ServiceError and surfaced through a dedicated application error event.
flowchart LR
INPUT["Customer input"] --> TYPE{"Structured payload?"}
TYPE -->|"yes"| STORE["dispatch('store')<br/>deterministic transition"]
TYPE -->|"no"| SANITIZE["sanitizeConversation<br/>remove executable descriptor fields"]
SANITIZE --> SERVICE["LlmService<br/>live or mock implementation"]
SERVICE --> API["POST /api/llm/chat"]
API --> RESULT["Text response and/or state patch"]
RESULT --> CONV["Conversation record"]
RESULT --> STATE["Validated state update"]
This boundary preserves deterministic behavior where the application already has structured inputs, while retaining a narrow adapter for open-ended conversation and its failure modes. It also gives the engine a clean mock seam for Storybook and automated verification.
What the Refactor Clarified
The frontend refactor also clarified the next architectural boundary. Before it, business
rules, interaction selection, workflow state, transcript mutation, and event handling were
interleaved in useChat. That made it difficult to distinguish the data a client should render
from the domain data and decisions a backend must own.
Separating canonical state, step handlers, descriptors, messages, and service boundaries created that contract. It made the actual inputs and outputs explicit: a customer submits a typed intent; the system validates it, changes canonical state, resolves the next step, selects an eligible interaction, and returns messages plus renderable projections. That is the appropriate boundary for a backend-owned conversation service.
The existing application did have backend event handling, but too much authority remained front-loaded in the browser. The client could calculate the next step, choose a tool, mutate workflow state, and construct prompts independently. That fragmented ownership made an event stream hard to trace, prevented the same core logic from being reused reliably, and limited the conversation to one frontend implementation.
flowchart LR
subgraph Before["Before: frontend owns the conversation"]
CLIENT1["useChat"] --> STATE1["State + transitions"]
CLIENT1 --> TOOLS1["Tool selection"]
CLIENT1 --> EVENTS1["Messages + event handling"]
end
subgraph Intermediate["Intermediate: refactor exposes contracts"]
STATE2["Canonical state"] --> HANDLERS["Validation + handlers"]
HANDLERS --> DESCRIPTORS["Tool descriptors"]
DESCRIPTORS --> CONTRACT["Explicit inputs and outputs"]
end
subgraph Target["Target: backend owns the conversation"]
COMMANDS["Versioned commands"] --> SERVICE["Conversation service"]
SERVICE --> SNAPSHOT["State, messages, projections\n& interaction manifest"]
SNAPSHOT --> CLIENT2["Web / mobile renderers"]
end
Before --> Intermediate --> Target
Internationalization for Language Access
The two frontend implementations differ materially in their internationalization posture. The
newer client initializes i18next and react-i18next, resolves translation keys in chat,
stepper, landing, wallet, and tool components, and stores its copy in a locale catalog. The
older frontend centralizes hard-coded English copy in TypeScript modules but has no locale
runtime, provider, locale routing, or translation lookup.
The organization is US-focused, so the goal of internationalization is language access: customers who are not native English speakers should be able to understand the chatbot's prompts, field labels, options, and errors without changing the underlying insurance workflow. The newer client's catalog-based structure is the foundation for that work.
This distinction matters when the conversation moves to the backend. A customer's display language is presentation context, not workflow state: someone choosing homeowner coverage should persist the same semantic value in every language. The conversation session or command carries an accepted BCP 47 language tag; the service persists or derives it from the customer profile; and the flow resolver continues to work only with language-neutral step IDs and values.
flowchart LR
CLIENT["Web or mobile client<br/>display language: es-US"] --> COMMAND["Conversation command<br/>+ presentation context"]
COMMAND --> SERVICE["Conversation service<br/>semantic state + flow resolver"]
SERVICE --> PROMPT["Assistant message<br/>in selected language"]
SERVICE --> MANIFEST["Interaction manifest<br/>semantic values + label keys"]
MANIFEST --> RENDERER["Client localization runtime<br/>localized accessible control"]
RENDERER --> VALUE["Language-neutral value<br/>US business formats retained"]
VALUE --> COMMAND
The portable InteractionManifest therefore returns semantic option values, field keys, and
localization keys—not translated labels or React code. Each client resolves the active display language
through its own catalog and submits the same language-neutral values. This gives web and mobile
one workflow contract without forcing them to share a UI framework or translation package.
Next Step: A Backend-Owned Conversation Engine
The next architecture turns the conversation into a durable backend aggregate. The backend becomes the single authority for conversation history, workflow state, next-step resolution, interaction selection, model calls, integrations, and authorization. The web client submits a versioned command and renders the authoritative snapshot or event it receives in return.
The core wire contract is deliberately portable. A ConversationSnapshot contains a
conversation ID and revision, client-safe state projection, message history, progress, and an
active InteractionManifest. A ConversationCommand carries a unique idempotency key, the
expected revision, an intent such as send_message or submit_interaction, and its values.
This replaces a client-sent transcript and locally calculated next step with a request the
backend can validate atomically.
sequenceDiagram
participant C as Web or mobile client
participant API as Conversation API
participant S as Conversation service
participant R as Flow resolver / selector
participant DB as Durable conversation store
C->>API: submit command + commandId + expectedRevision
API->>S: authenticate, deduplicate, validate
S->>DB: append receipt, user message, and state mutation atomically
S->>R: resolve canonical state and select interaction
R-->>S: next step + portable interaction manifest
S->>DB: persist prompt, projection, and ordered event
S-->>C: snapshot/event at the new revision
C->>C: render message, progress, and interaction
The InteractionManifest is the important separation. It carries semantic values—a step ID,
interaction kind, fields, localization keys, enabled state, and message ID—but never React
components, predicates, or client validation code. The web and a future mobile application can
each map kind to a native, accessible control while sharing one business workflow.
This is what enables multichannel operation. Two devices no longer calculate competing next actions: both receive the same snapshot and submit revision-checked commands. An event cursor over SSE supports reconnect and replay; idempotent command receipts make retries safe; an anonymous session can be claimed by an authenticated customer without treating a client-provided transcript as authoritative.
What Moves, What Remains
| Current frontend owner | Backend-owned replacement | Client responsibility afterward |
|---|---|---|
ChatState.resolve() | FlowResolver | Render the returned state projection. |
StepHandlerFactory, handlers, and TOOL_REGISTRY conditions | Command validators plus InteractionSelector and a versioned interaction catalog | Render an interaction by manifest kind; submit its values. |
useStep prompt creation | Persisted assistant prompt and interaction-change event | Render the server-supplied message. |
useStepper | Server progress projection | Format and display progress. |
useFreeformMessageHandler and local history transport | Server-owned message history and model/integration adapter | Submit freeform text only. |
Separate useChat, useStep, useStepper, and service owners | One durable conversation application service | An evolved useConversation session hook for snapshots, events, and commands. |
The end state is intentionally small on the client. The evolved useConversation owns only
transport/cache concerns and transient interface state—loading, focus, scroll position, and an
optimistic selection until it is reconciled. It does not resolve a step, select a tool, append
canonical history, or mutate business state. That removes the need for the clustered hook
topology while retaining the single hook a chat interface actually needs.
Incremental Backend Migration
This migration is designed around complete state-transition slices. useChat remains for any legacy flow that still depends on local handlers or tool
selection; it is the last local owner to retire, not the first.
- Define one canonical, versioned snapshot, command, event, and interaction-manifest schema. Generate web and mobile models from it rather than duplicating step unions and payload types.
- Add a shared client response applier that reconciles an optimistic message, accepted message, assistant messages, active interaction, progress, and wallet projection from one normalized backend response.
- Put one complete structured flow behind a capability gate. Its tool submits values to the command endpoint; the backend validates, stores, resolves, and selects the next interaction.
- Add create/resume snapshots and ordered SSE events so the first prompt, reconnection, stale command handling, and historic inactive interactions are backend-authoritative.
- Expand by complete flow slice. Retire local handlers,
useStep,useStepper, and finallyuseChatonly after their server replacements are live for every active path.
This preserves a single source of truth throughout the migration. It also makes the transition measurable: a second device receives the same active interaction, a repeated command produces one state transition, and a reconnect reaches the same revision as a fresh snapshot.
Verification and Evaluation Strategy
The refactor creates small evaluation surfaces instead of requiring every change to exercise a full browser conversation. The backend migration extends that discipline: each test checks a contract or state transition at its owner, then a smaller set of end-to-end tests verifies that web, mobile, and reconnecting clients observe the same durable result.
| Surface | Evaluation focus |
|---|---|
| State resolution | ChatState.resolve() snapshots produce the expected next step for each insurance branch during the frontend phase; the backend FlowResolver must preserve those outcomes after migration. |
| Handlers and descriptor contracts | Valid structured values update only their owned domain fields; invalid values fail with typed errors. Registry predicates select the expected interaction, and Valibot contracts become executable boundary validation. |
| AI conversation routing | Payload-bearing messages bypass the model. Freeform input is sanitized before transport and may yield generated text, a validated state patch, or both; a model error cannot bypass the resolver. |
| API contract | Generated web and mobile models validate the versioned snapshot, command, event, and interaction-manifest schemas so no client reimplements step or payload vocabularies. |
| Command service | A duplicate commandId returns one accepted result (idempotency), stale revisions and stale interaction instances are rejected, and authorization is checked before any state change. |
| Event recovery | Replaying ordered events from a cursor reaches the same revision as a fresh snapshot; a missing cursor forces a safe snapshot replacement rather than local reconstruction. |
| Multichannel behavior | Two clients submitting concurrently cannot create competing next steps. Offline retry, sign-in claim, historic inactive interactions, and a mobile/web handoff all converge on the same server state. |
| Language access | Language selection, fallback, catalog coverage, accessible translated copy, and language-neutral stored values are verified independently of workflow resolution; US currency and date formats remain intentional. |
| Incremental rollout | A feature-flagged flow produces one initial prompt, server-owned progress, and no local dispatch call before its legacy handler, useStep, or useStepper counterpart is removed. |
The success criteria are concrete: a structured selection creates one canonical user message after retries; no client code selects the next interaction; and a newly connected device sees the same snapshot, active interaction, and progress as the device that made the last change.
Final Architecture and Benefits
The immediate release result was Microsoft Entra ID authentication shipped in one week of critical, part-time work. The larger result is an architecture that takes the conversation from a browser-bound orchestration hook to a durable service that can support more products, channels, and users without duplicating workflow logic.
| Architectural element | Final responsibility | Benefit |
|---|---|---|
| Conversation service | Owns canonical history, state, business rules, authorization, integrations, and model invocation. | One source of truth for every conversation, regardless of client or connection. |
FlowResolver and InteractionSelector | Resolve the next required step and active interaction from server-owned state. | No browser independently decides what happens next; workflow behavior is consistent and reusable. |
| Versioned commands, snapshots, and events | Apply idempotent, revision-checked mutations and publish durable results. | Safe retries, stale-action protection, reconnect recovery, and concurrent multichannel use. |
Portable InteractionManifest | Describes semantic fields, options, and enabled state without shipping React behavior. | Web and mobile render native, accessible interfaces while sharing one business workflow. |
| Display language as presentation context | Carries an accepted language tag and returns localized prompt text plus semantic keys for deterministic controls. | Non-native English speakers can receive understandable copy without locale-specific branches, translated labels changing the workflow, or a change to US business formats. |
Evolved useConversation | Caches snapshots and events, submits commands, and owns only transient interface state. | Replaces the clustered hook topology with one focused client boundary. |
| Bounded AI adapter | Handles freeform input and generated responses, then routes any proposed state change through validation and resolution. | The product can accept open-ended input without allowing generation to bypass insurance rules. |
| Focused modules and contracts | Isolate state, validation, interaction rendering, transport, and projections. | A new flow is a bounded contract change; developers and LLM-assisted tools load less context, use fewer tokens, and make more reliable edits. |