Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.waniwani.ai/llms.txt

Use this file to discover all available pages before exploring further.

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 question extracts multiple state fields at once.

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

import { createFlow, END, START } from "@waniwani/sdk/mcp";
import { z } from "zod";
import { petApi } from "./api";
import { showPetSummaryTool, showPricingTool } from "./display-tools";

export const quoteFlow = createFlow({
  id: "pet_insurance_quote",
  title: "Pet Insurance Quote",
  description: `Get a pet insurance quote. Use when a user wants to insure their dog or cat, get a price quote, or learn about available insurance packages.

NO-RECOMMENDATION RULE (CRITICAL - applies to the ENTIRE conversation):
- NEVER recommend, suggest, or steer the user toward any specific plan, not even implicitly.
- NEVER use social proof ("most people choose…") or comparative framing ("best protection…").
- When asked "what do you recommend?", warmly decline: explain you can only provide factual information, and invite them to ask about coverage details so they can decide themselves.
- When comparing plans, present ONLY factual differences (price, limits, what's included).

TONE - conversational, not a form:
- You are a warm, knowledgeable assistant, like a friend who happens to know everything about pet insurance.
- React to what the user shares with genuine interest (e.g., "Goldens are such great dogs!")
- Use the pet's name once you know it.
- Keep text short. Use widgets for structured information.
- Explain difficult concepts simply, avoid insurance jargon.

DO: Talk like a friend. React genuinely. Be frank and truthful.
DON'T: Sound sales-y. Be judgmental. Use walls of text. Be robotic.`,
  state: {
    animalType: z
      .enum(["dog", "cat"])
      .describe(
        "Type of animal. Infer from the breed when obvious (e.g. Golden Retriever → dog, Persian → cat).",
      ),
    petName: z.string().describe("The pet's name"),
    breed: z
      .string()
      .describe(
        "The pet's breed as stated by the user. Fix obvious spelling mistakes (e.g. 'Retriver' → 'Retriever'). Leave empty when isMixedBreed is true.",
      ),
    breedId: z
      .string()
      .describe("Resolved breed ID from the API (set by validation, not the user)"),
    isMixedBreed: z
      .boolean()
      .optional()
      .describe(
        "Whether the pet is a mixed breed. Set to true when user mentions a mix, crossbreed, or multiple breeds. When setting to true, MUST also set mixedBreed to {} in the same stateUpdates.",
      ),
    mixedBreed: z
      .object({
        knowsBreeds: z
          .boolean()
          .describe("true if user named at least one breed, false if they don't know"),
        breed1: z.string().optional().describe("First breed in the mix"),
        breed1Id: z.string().optional().describe("First breed API ID (set by system)"),
        breed2: z.string().optional().describe("Second breed in the mix (optional)"),
        breed2Id: z.string().optional().describe("Second breed API ID (set by system)"),
      })
      .optional()
      .describe(
        "MUST be set whenever isMixedBreed is true. If user named breeds: { knowsBreeds: true, breed1: '...', breed2: '...' }. If user just said 'mixed' without naming breeds: { knowsBreeds: false }.",
      ),
    gender: z
      .enum(["male", "female"])
      .describe(
        "The pet's gender. Infer from pronouns: he/him/his → male, she/her → female. Also accept boy/girl.",
      ),
    isNeutered: z.boolean().describe("Whether the pet is neutered/spayed"),
    birthDate: z
      .string()
      .describe(
        "Birth date in YYYY-MM-DD format. Accept any format from the user ('2 years old', 'March 2023', etc.) and convert yourself. Never ask the user to reformat.",
      ),
    indoorOutdoor: z
      .enum(["indoor", "outdoor"])
      .optional()
      .describe("Whether the cat is indoor or outdoor (cats only, skip for dogs)"),
    municipality: z.string().describe("City or municipality where the owner lives"),
    isCurrentlyInsured: z
      .enum(["yes", "no"])
      .describe("Whether the pet is currently insured with another provider"),
    confirmed: z.boolean().describe("Whether the user confirmed their pet details"),
    quoteId: z.string().describe("Quote ID returned by the pricing API"),
    deductible: z
      .number()
      .optional()
      .describe("Fixed deductible amount. Default: 500. Options: 0, 250, 500, 750."),
    copay: z
      .number()
      .optional()
      .describe(
        "Copay percentage as decimal. Default: 0.20. Options: 0.15 (15%), 0.20 (20%), 0.25 (25%).",
      ),
    pricingAction: z
      .enum(["adjust", "done"])
      .describe(
        "Set to 'adjust' when user wants to change deductible/copay. Set to 'done' when satisfied.",
      ),
  },
})

  // ─── Node 1: Open-ended question ─────────────────────────────────────
  // One natural question → extract as many fields as possible from free-form text
  .addNode({
    id: "ask_about_pet",
    run: ({ interrupt }) => {
      return interrupt({
        petName: {
          question: "Tell me about your pet!",
          context: `Warmly greet the user and ask an OPEN-ENDED question. Something like:
"I'd love to help you explore insurance options! Tell me a bit about your pet - what kind of animal, their name, breed, age… anything you'd like to share :)"

Do NOT list specific questions. Let the user share naturally.

From their response, extract as many fields as possible into stateUpdates:
- animalType: infer from breed when obvious
- petName, breed, gender (from pronouns), isNeutered, birthDate (any format → YYYY-MM-DD)
- isCurrentlyInsured, municipality
- isMixedBreed + mixedBreed if they mention a mix

Only extract what is clearly stated. Do NOT guess missing fields.`,
        },
      });
    },
  })

  // ─── Node 2: Conversational follow-ups ───────────────────────────────
  // Only ask what's still missing, 1-2 questions at a time
  .addNode({
    id: "ask_remaining_details",
    run: async ({ state, interrupt }) => {
    const breedList = await petApi.getBreedList(state.animalType);

    const sharedContext = `You're having a natural conversation, NOT filling out a form. Ask remaining questions ONE OR TWO at a time, weaving them in naturally. React to what the user shared previously with genuine interest before asking the next thing.

Good follow-ups:
- "Love that name! And what breed is ${state.petName}?"
- "A Golden Retriever, great choice! Is ${state.petName} a boy or a girl?"
- "Got it! And roughly how old is ${state.petName}?"

${breedList ? `Valid breed names: ${breedList}. Match the user's input to the closest name from this list.` : ""}

IMPORTANT: Do NOT ask all remaining questions at once. Keep it conversational.
FORMATTING: Write in flowing prose, not bullet points.`;

    return interrupt(
      {
        ...(!state.animalType
          ? { animalType: { question: "Is it a dog or a cat?" } }
          : {}),
        ...(!state.petName
          ? { petName: { question: "What's your pet's name?" } }
          : {}),
        ...(!state.breed && !state.isMixedBreed
          ? {
              breed: {
                question: "What breed?",
                context: breedList
                  ? `Valid breeds: ${breedList}. Match the user's input to the closest official name.`
                  : undefined,
                validate: state.animalType
                  ? async (breed: string) => {
                      return petApi.validateBreed(state.animalType!, breed);
                    }
                  : undefined,
              },
            }
          : {}),
        ...(!state.gender ? { gender: { question: "Male or female?" } } : {}),
        ...(state.isNeutered == null
          ? { isNeutered: { question: "Neutered/spayed?" } }
          : {}),
        ...(!state.birthDate
          ? { birthDate: { question: "When was your pet born?" } }
          : {}),
        ...(!state.isCurrentlyInsured
          ? { isCurrentlyInsured: { question: "Currently insured?" } }
          : {}),
        ...(!state.municipality
          ? { municipality: { question: "What city do you live in?" } }
          : {}),
        ...(state.animalType === "cat" && !state.indoorOutdoor
          ? { indoorOutdoor: { question: "Indoor or outdoor cat?" } }
          : {}),
      },
      { context: sharedContext },
    );
    },
  })

  // ─── Node 3: Mixed breed - collect breed names ───────────────────────
  .addNode({
    id: "ask_mixed_breed_breeds",
    run: async ({ state, interrupt }) => {
      if (!state.isMixedBreed || state.mixedBreed?.breed1) return {};

      const breedList = await petApi.getBreedList(state.animalType);

      return interrupt({
        "mixedBreed.breed1": {
          question: "Which breeds is your dog a mix of?",
          context: `Ask naturally: "Which breeds is ${state.petName} a mix of?" They must provide at least one, the second is optional.${breedList ? `\n\nValid breeds: ${breedList}. Match each to the closest official name.` : ""}`,
          validate: async (breed1) => {
            if (!breed1) return;
            return petApi.validateMixedBreed(state.animalType!, {
              ...state.mixedBreed,
              breed1,
            });
          },
        },
      });
    },
  })

  // ─── Node 4: Validate breed + location against API ───────────────────
  .addNode({
    id: "validate_details",
    run: async ({ state }) => {
      const [breedResult, locationResult] = await Promise.all([
        state.isMixedBreed && state.mixedBreed
          ? petApi.validateMixedBreed(state.animalType!, state.mixedBreed)
          : petApi.validateBreed(state.animalType!, state.breed!),
        petApi.validateLocation(state.municipality!),
      ]);

      return { ...breedResult, ...locationResult };
    },
  })

  // ─── Node 5: Show confirmation widget ────────────────────────────────
  .addNode({
    id: "show_confirmation",
    run: ({ state, showWidget }) => {
      const mb = state.mixedBreed;
      let displayBreed = state.breed ?? "";
      if (mb) {
        const parts = [mb.breed1, mb.breed2].filter(Boolean);
        displayBreed = parts.length > 0 ? `${parts.join(" & ")} Mix` : "Mixed breed";
      }

      return showWidget("show_pet_summary", {
        field: "confirmed",
        description:
          "You MUST now call the show_pet_summary tool with the pet data to display the summary card. Write a short, excited message first, something like 'Here's what I've got for [petName], take a look!' Then immediately call show_pet_summary. The widget displays all the details, so do NOT repeat them in your text. After the widget renders, ask the user to confirm or let you know if anything needs fixing. If the user corrects anything, set confirmed to false and extract the correction. Only set confirmed to true when they explicitly say everything is correct.",
        data: {
          animalType: state.animalType,
          petName: state.petName,
          breed: displayBreed,
          gender: state.gender,
          isNeutered: state.isNeutered,
          birthDate: state.birthDate,
          indoorOutdoor: state.indoorOutdoor,
          municipality: state.municipality,
        },
      });
    },
  })

  // ─── Node 5b: Reset confirmed for correction loop ───────────────────
  .addNode({
    id: "fix_details",
    run: () => ({ confirmed: undefined }),
  })

  // ─── Node 6: Show pricing widget ────────────────────────────────────
  .addNode({
    id: "show_pricing",
    run: async ({ state, showWidget }) => {
      const currentDeductible = state.deductible ?? 500;
      const currentCopay = state.copay ?? 0.2;

      const quote = await petApi.getQuote({
        animalType: state.animalType!,
        breedId: state.breedId!,
        birthDate: state.birthDate!,
        gender: state.gender!,
        neutered: !!state.isNeutered,
        municipality: state.municipality!,
        deductible: currentDeductible,
        copay: currentCopay,
      });

      return {
        quoteId: quote.id,
        ...showWidget("show_pricing", {
          description: `You MUST now call the show_pricing tool with the plans data to display the pricing cards. Write a brief intro, something like "Here are the plans available for [petName]!" Then immediately call show_pricing. COMPLIANCE: Do NOT recommend any plan. Only present factual differences. After the widget renders, let the user browse. If they want to adjust their deductible (options: 0, 250, 500, 750) or copay (options: 15%, 20%, 25%), set the new values and set pricingAction to "adjust". If they're happy, set pricingAction to "done".`,
          data: {
            petName: state.petName,
            quoteId: quote.id,
            plans: quote.plans,
          },
        }),
      };
    },
  })

  // ─── Node 6b: Reset pricingAction for adjustment loop ───────────────
  .addNode({
    id: "recalculate_pricing",
    run: () => ({ pricingAction: undefined as unknown as "adjust" | "done" }),
  })

  // ─── Edges ──────────────────────────────────────────────────────────
  .addEdge(START, "ask_about_pet")
  .addEdge("ask_about_pet", "ask_remaining_details")
  .addConditionalEdge("ask_remaining_details", (state) => {
    if (state.animalType === "dog" && state.isMixedBreed)
      return "ask_mixed_breed_breeds";
    return "validate_details";
  })
  .addEdge("ask_mixed_breed_breeds", "validate_details")
  .addEdge("validate_details", "show_confirmation")
  .addConditionalEdge("show_confirmation", (state) =>
    state.confirmed ? "show_pricing" : "fix_details",
  )
  .addEdge("fix_details", "validate_details")
  .addEdge("show_pricing", "recalculate_pricing")
  .addEdge("recalculate_pricing", "show_pricing")
  .compile();

Register the widget display tools

showWidget("show_pet_summary", ...) and showWidget("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
import { McpServer } from "skybridge/server";
import { withWaniwani } from "@waniwani/sdk/mcp";
import { z } from "zod";
import { quoteFlow } from "./flows/quote.js";

export const server = new McpServer(
  { name: "pet-insurance-mcp", version: "0.0.1" },
  { capabilities: {} },
)
  .registerWidget(
    "show_pet_summary",
    {
      description:
        "You MUST call this tool to display the pet summary card. Do NOT repeat the pet details in text.",
    },
    {
      inputSchema: {
        animalType: z.enum(["dog", "cat"]),
        petName: z.string(),
        breed: z.string(),
        gender: z.enum(["male", "female"]),
        isNeutered: z.boolean(),
        birthDate: z.string(),
        indoorOutdoor: z.enum(["indoor", "outdoor"]).optional(),
        municipality: z.string(),
      },
      annotations: { readOnlyHint: true, openWorldHint: false },
    },
    async (input) => ({
      structuredContent: input,
      content: [{ type: "text", text: `Pet summary for ${input.petName}` }],
      isError: false,
    }),
  )
  .registerWidget(
    "show_pricing",
    {
      description:
        "You MUST call this tool to display the pricing plan grid. The widget renders every plan — do NOT list them in text.",
    },
    {
      inputSchema: {
        petName: z.string(),
        quoteId: z.string(),
        plans: z.array(
          z.object({
            name: z.string(),
            monthlyPrice: z.number(),
            coverageLimit: z.number(),
            deductible: z.number(),
            copay: z.number(),
            highlights: z.array(z.string()),
          }),
        ),
      },
      annotations: { readOnlyHint: true, openWorldHint: false },
    },
    async (input) => ({
      structuredContent: input,
      content: [{ type: "text", text: `Pricing for ${input.petName}` }],
      isError: false,
    }),
  )
  .registerTool(quoteFlow.name, quoteFlow.config, quoteFlow.handler);

withWaniwani(server);
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 but declares petName as its interrupt field. The context instructs the model to extract all recognizable fields from the user’s free-form response into stateUpdates.This creates the feeling of a natural conversation: the user says “I have a 2-year-old Golden Retriever named Max, he’s neutered” and the flow captures 5+ fields in one turn. The next node then only asks what’s still missing.
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 + correction in stateUpdates → 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 model sets the new deductible value and pricingAction: "adjust". The recalculate_pricing node resets pricingAction to undefined, which loops back to show_pricing, which calls the API with the new parameters and re-renders the widget.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("show_pricing", { description, data }) and pauses.
  2. The flow engine returns an interrupt to the LLM with the description you provided.
  3. The LLM reads that description (which says “You MUST now call the show_pricing tool”), 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 description is the key bridge. It’s your instruction to the LLM about what tool to call and how to frame it conversationally. This is why it says things like “Write a brief intro, then immediately call show_pet_summary” rather than just describing what the widget does.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.
// The flow pauses here and tells the LLM:
// "Call show_pet_summary with this data"
return showWidget("show_pet_summary", {
  field: "confirmed",  // Which state field this resolves
  description: "You MUST now call the show_pet_summary tool...",
  data: { petName, breed, ... },  // Passed to the widget
});
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:
validate: async (breed: string) => {
  return petApi.validateBreed(state.animalType!, breed);
}
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.