Skip to main content
This is a real flow shipped in production by a Waniwani customer — a pet insurance distributor selling policies through their own MCP. It’s the kind of build we open-sourced Waniwani for: a multi-step quote conversation that has to feel natural, validate input strictly, branch on edge cases (mixed-breed dogs), and let the user correct themselves without restarting. We’re showing it here, simplified and anonymized, because every pattern in it is one we hit repeatedly across customer projects — and the shape of the answer is what createFlow was designed around.
API calls use a fictional petApi and the pricing logic is anonymized. The graph shape, validation patterns, widget hand-off, and correction loop are exactly as deployed.

Flow graph

Patterns demonstrated

Open-ended questions

One natural opening question. Whatever else the user shares answers the follow-ups without asking again.

Conversational follow-ups

Asks remaining questions 1-2 at a time with warm, contextual reactions.

Rich system prompt

Compliance rules and tone guidance embedded in the tool description.

Conditional branching

Different paths based on animal type and breed composition.

Async validation

Server-side breed and location validation between questions and confirmation.

Widget display

Confirmation card and pricing cards rendered via display tools.

Correction loop

User fixes details → re-validate → re-display until confirmed.

Pricing adjustment loop

User tweaks deductible/copay → recalculate → show updated prices.

Full flow

Register the widget display tools

showWidget({ tool: "show_pet_summary", ... }) and showWidget({ tool: "show_pricing", ... }) in the flow refer to widget names the model will call to render each card. Register them with Skybridge’s registerWidget — it binds the named widget to the matching React component under web/src/widgets/ and handles all of the OpenAI/Claude render plumbing for you:
server/src/app.ts
The widget itself lives at web/src/widgets/show_pet_summary.tsx (and show_pricing.tsx) and mounts via mountWidget from skybridge/web. The widget name passed to registerWidget matches the file name — that’s how Skybridge wires the binding, so you never deal with _meta.openai/outputTemplate URIs by hand.
Skybridge ships in the MCP distribution template as the default dev/build runtime. If you’re starting from that template (bun dev runs skybridge dev), registerWidget is already available on the server you instantiate. See the Skybridge docs for the full widget mounting API.

Key patterns explained

The description field of a flow becomes the MCP tool’s description. It’s the first thing the model reads when deciding how to use the tool. By embedding compliance rules and tone guidance here, you ensure every single interaction follows your guidelines without relying on external system prompts you don’t control.This is especially powerful for regulated industries (insurance, finance, healthcare) where the AI must never give recommendations or advice.
The ask_about_pet node asks one broad question and declares only petName. It is the session’s first question, so its response also lists every field the conversation may fill under fields. When the user answers “I have a 2-year-old Golden Retriever named Max, he’s neutered”, the model sends petName along with the animal type, breed, mixed-breed status, gender, neutering and birth date it reads from Max’s description, each matched to its type and enum values.Every value that passes its field’s schema is written. ask_remaining_details then filters out the questions those values answer, so only insurance status and municipality reach the user, and the breed validator still checks “Golden Retriever” once that interrupt’s fields are all filled.The breed question stays in the interrupt even once breed is filled. The engine skips it, and its validator still runs on a breed the model sent early.The two widget picks (confirmed, pricingChoice) are wrapped in neverPrefilled(). fields leaves them out and the engine drops any value for them until the flow asks, so the summary and pricing widgets always reach the user. The breed ids and the quote id stay in state: validate_details and show_pricing write them on every pass, so their values always come from the API.
After show_confirmation displays the summary widget:
  • If the user says “looks good” → confirmed = true → proceed to pricing
  • If the user says “actually his name is Max, not Mac” → confirmed = false plus the corrected petName in stateUpdates (the flow asked for petName earlier, so the engine accepts it) → fix_details resets confirmed → validate_details re-runs → show_confirmation re-displays with corrected data
This loop continues until the user explicitly confirms. The flow never proceeds with incorrect data.
After showing pricing plans, the user might say “can I see prices with a lower deductible?” The pricing widget asks for pricingChoice, a z.object() group, so the model can send { action: "adjust", deductible: 250 } in one value: a question on a group accepts every key inside it. recalculate_pricing copies the new values onto deductible and copay, clears pricingChoice, and loops back to show_pricing, which calls the API with the new parameters and re-renders the widget. { action: "done" } ends the flow.This pattern works for any “configure → preview → adjust” cycle.
Flows don’t render widgets directly. Instead, showWidget creates a special interrupt that instructs the LLM to call a separate display tool. Here’s the sequence:
  1. The flow node calls showWidget({ tool: "show_pricing", data }) and pauses.
  2. The flow engine returns an interrupt to the LLM with a standardized instruction to call the named display tool.
  3. The LLM writes a short intro message, then calls the display tool.
  4. The display tool renders the widget in the chat UI.
  5. After the user interacts with the widget, the LLM continues the flow with updated state.
The field parameter tells the flow which state field the widget interaction resolves (e.g., confirmed). The data is passed directly to the widget component for rendering. Any per-widget AI guidance (conversational framing, compliance notes, etc.) belongs in the display tool’s own response, not in showWidget.
This separation means the flow controls when and with what data a widget appears, while the LLM controls the conversational framing around it.
The breed interrupt field in ask_remaining_details has a validate callback:
This runs server-side when the model provides a breed name. If the breed doesn’t exist in the database, the validation throws an error with suggestions and the flow automatically re-asks the user. If it succeeds, it can return state updates (like breedId) that enrich the state without an extra round-trip.