---
title: Multi-Step Wizard
description: Let the LLM sequence a back-navigable wizard by minting a fresh render per step and prefilling on back-navigation.
---

:::note[Endpoint defaults]
This recipe points the agent host at a ggui MCP URL via `GGUI_MCP_URL` / `GGUI_API_KEY`. The documented default is self-hosted: `GGUI_MCP_URL=http://127.0.0.1:6781/mcp` with `GGUI_API_KEY=dev` (pairs with `ggui serve --dev-allow-all`). On the hosted platform (coming soon), use the bare universal endpoint `https://mcp.ggui.ai/` or your per-app endpoint `https://mcp.ggui.ai/apps/<appId>` — no `/mcp` suffix — with a `ggui_sk_*` API key. The contract shape and prompt do not change. See the [OSS Quick Start](/oss-quickstart/) for the local-first walkthrough.
:::

Wizards in ggui mint a **fresh GguiSession per step** (each keyed by its own `sessionId`). Each step is its own `handshake → render → consume` round-trip; back-navigation re-renders the prior step with the prior `actionData` plumbed in as `props.prefill`.

## Pattern

1. The host opens an MCP session against the ggui server and exposes the `ggui_*` tools to the LLM. No wrapper SDK in the agent process.
2. You describe the wizard once in the user prompt and define one typed `defineContract` per step.
3. The LLM autonomously sequences: `ggui_handshake` → `ggui_render` → `ggui_consume` → react on `intent` → next step's `ggui_handshake` → `ggui_render` → ... .
4. If a consume entry's `intent` is `"back"`, the LLM re-handshakes the prior step with `props.prefill` carrying the prior `actionData` so values are restored.

:::note[Consume vs WebSocket envelopes]
`ggui_consume` returns `events: ConsumeEventEntry[]` — each row has shape `{ type: 'action', intent, actionData, uiContext, ... }`. That is distinct from the inbound `ActionEnvelope` (`{ type: 'data:submit', payload }`) that the iframe pushes over the WebSocket; consume reads from a separate render-scoped pipe whose entries originate at `submit_action`. The LLM matches on `entry.intent` (the `actionSpec[*]` key) and reads `entry.actionData` for the typed payload.
:::

## LLM-driven flow (canonical — Claude Agent SDK)

Configure a Claude Agent SDK host with the ggui MCP server, allow the `ggui_*` tools, and ship a wizard-shaped prompt. The model does the rest.

```typescript
import { query } from "@anthropic-ai/claude-agent-sdk";
import { GGUI_AGENT_SYSTEM_PROMPT, defineContract } from "@ggui-ai/protocol";

// One contract per step. `defineContract({...} as const)` infers TS payload
// types from the JSON Schemas — no parallel interface definitions.
// A DataContract declares data flow only (propsSpec / actionSpec /
// streamSpec / contextSpec) — there are no `name` or `layout` fields.
// The step header ("Step 1 of 3 — Personal Information") travels as the
// handshake `intent` (and, for finer aim, `blueprintDraft.variance.seedPrompt`).
const personalInfoContract = defineContract({
  actionSpec: {
    submit: {
      label: "Next",
      schema: {
        type: "object",
        required: ["fullName", "email", "phone"],
        properties: {
          fullName: { type: "string" },
          email: { type: "string", format: "email" },
          phone: { type: "string" },
        },
      },
    },
  },
} as const);

// ... companyInfoContract + reviewContract defined similarly (also expose a
// "back" actionSpec entry on step 2 and 3).

for await (const event of query({
  prompt: `Walk me through a 3-step onboarding wizard.

Step 1 (Personal Info): use this contract verbatim
${JSON.stringify(personalInfoContract)}

Step 2 (Company Info, with Back): use this contract verbatim
${JSON.stringify(companyInfoContract)}

Step 3 (Review & Confirm): use this contract verbatim
${JSON.stringify(reviewContract)}
Render step 3 with props.summary = {personal, company} so the summary can render.

Navigation rules:
- After each ggui_render, call ggui_consume and inspect events[].intent.
- intent === "submit": advance to the next step's handshake + render, threading
  prior data via the render's props.
- intent === "back": re-handshake the prior step's contract with
  props.prefill = priorActionData so values are restored.
- After step 3 submit, simply stop — renders decay implicitly via TTL.`,
  options: {
    model: "claude-haiku-4-5",
    systemPrompt: GGUI_AGENT_SYSTEM_PROMPT,
    mcpServers: {
      ggui: {
        type: "http",
        url: process.env.GGUI_MCP_URL!, // e.g. http://127.0.0.1:6781/mcp
        headers: { Authorization: `Bearer ${process.env.GGUI_API_KEY!}` },
      },
    },
    allowedTools: [
      "mcp__ggui__ggui_handshake",
      "mcp__ggui__ggui_render",
      "mcp__ggui__ggui_consume",
    ],
    tools: [],
    strictMcpConfig: true,
  },
})) {
  // Inspect events for logging; the LLM drives each render autonomously.
  if (event.type === "assistant") console.log(event.message);
}
```

:::tip[Typed payloads via `defineContract`]
`defineContract({...} as const)` lets `InferActionPayload<typeof personalInfoContract, "submit">` resolve to `{ fullName: string; email: string; phone: string }` — the same shape the agent validates server-side via `actionSpec.submit.schema`. No `Record<string, unknown>`, no parallel interfaces. See [Contract](/glossary/#contract).
:::

## Manual orchestration (raw MCP, no Claude in the loop)

When you need explicit control over each step — testing, deterministic playback, non-LLM driver — open a raw MCP session and call `ggui_*` tools yourself.

```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { defineContract } from "@ggui-ai/protocol";

const transport = new StreamableHTTPClientTransport(new URL(process.env.GGUI_MCP_URL!), {
  requestInit: { headers: { Authorization: `Bearer ${process.env.GGUI_API_KEY!}` } },
});
const mcp = new Client({ name: "wizard-driver", version: "1.0.0" });
await mcp.connect(transport);

async function call<T>(name: string, args: unknown): Promise<T> {
  const res = await mcp.callTool({ name, arguments: args as Record<string, unknown> });
  return JSON.parse((res.content[0] as { text: string }).text) as T;
}

async function step<TIntent extends string>(
  contract: unknown,
  intent: string,
  prefill?: Record<string, unknown>
): Promise<{ intent: TIntent; actionData: unknown; sessionId: string }> {
  // ggui_handshake({ intent, blueprintDraft }) → { handshakeId, action, suggestion }
  const hs = await call<{ handshakeId: string }>("ggui_handshake", {
    intent,
    blueprintDraft: { contract },
  });
  // ggui_render({ handshakeId, props }) — props is REQUIRED (pass {} when none).
  const { sessionId } = await call<{ sessionId: string }>("ggui_render", {
    handshakeId: hs.handshakeId,
    props: prefill ? { prefill } : {},
  });
  // Long-poll ggui_consume until the user submits or goes back.
  for (;;) {
    const { events, status } = await call<{
      events: Array<{ intent: string; actionData: unknown }>;
      status: "active" | "expired";
    }>("ggui_consume", { sessionId, timeout: 25 });
    // `intent` is the actionSpec key the iframe dispatched against — "back"
    // is its own entry, not a sub-field of "submit".
    const entry = events.find((e) => e.intent === "back" || e.intent === "submit");
    if (entry) return { ...(entry as { intent: TIntent; actionData: unknown }), sessionId };
    if (status === "expired") throw new Error("render expired before a terminal action");
  }
}

// Step 1
const personal = await step<"submit">(
  personalInfoContract,
  "Onboarding step 1 of 3 — personal info"
);

// Step 2 with back handling
let company: { intent: "submit"; actionData: unknown } | null = null;
while (!company) {
  const r = await step<"submit" | "back">(
    companyInfoContract,
    "Onboarding step 2 of 3 — company info"
  );
  if (r.intent === "back") {
    // Re-render step 1 with the prior actionData as prefill — a fresh sessionId
    // is minted; the prior render decays via TTL on its own.
    await step<"submit">(
      personalInfoContract,
      "Onboarding step 1 of 3 — personal info (revisit)",
      personal.actionData as Record<string, unknown>
    );
    continue;
  }
  company = r as { intent: "submit"; actionData: unknown };
}

// No explicit close — the render decays implicitly via TTL after step 3 submit.
```

## Step lineage

```
Step 1 render:        sessionId=r_p1   (personal info)
Step 2 render:        sessionId=r_c1   (company info)        ← user sees Company
User clicks Back:     -- no pop --     (r_c1 decays via TTL)
Step 1 revisit:       sessionId=r_p2   (prefilled with prior personal data)
Step 2 revisit:       sessionId=r_c2
Step 3:               sessionId=r_r1   (review & confirm)
```

Each step is an independent render with its own `sessionId`. There is no stack — renders are flat. Prior renders decay via TTL after the agent moves on.

## Inspecting a session's state

`ggui_get_session` returns the full `GguiSession` (id, appId, status, eventSequence, timestamps, plus the spec fields — propsSpec/actionSpec/contextSpec/streamSpec) — useful for debugging or for a driver that needs to know whether a session is still active.

```typescript
const state = await call<{ id: string; status: string }>("ggui_get_session", {
  sessionId: company!.sessionId,
});
console.log(`Render status: ${state.status}`);
```

## See also

- [Claude Agent example](/examples/claude-agent/) — end-to-end LLM-driven loop wired to a ggui MCP server
- [MCP protocol reference](/api/mcp-protocol/) — full `ggui_*` tool signatures
- [GguiSession](/glossary/#gguisession-a-render) — the unit minted by each step
- [Feedback Form](/cookbook/feedback-form/) — single-step variant of the same pattern