Multi-Step Wizard
read as.md 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
Section titled “Pattern”- 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. - You describe the wizard once in the user prompt and define one typed
defineContractper step. - The LLM autonomously sequences:
ggui_handshake→ggui_render→ggui_consume→ react onintent→ next step’sggui_handshake→ggui_render→ … . - If a consume entry’s
intentis"back", the LLM re-handshakes the prior step withprops.prefillcarrying the prioractionDataso values are restored.
LLM-driven flow (canonical — Claude Agent SDK)
Section titled “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.
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);}Manual orchestration (raw MCP, no Claude in the loop)
Section titled “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.
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 1const personal = await step<"submit">( personalInfoContract, "Onboarding step 1 of 3 — personal info");
// Step 2 with back handlinglet 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
Section titled “Step lineage”Step 1 render: sessionId=r_p1 (personal info)Step 2 render: sessionId=r_c1 (company info) ← user sees CompanyUser 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_c2Step 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
Section titled “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.
const state = await call<{ id: string; status: string }>("ggui_get_session", { sessionId: company!.sessionId,});console.log(`Render status: ${state.status}`);See also
Section titled “See also”- Claude Agent example — end-to-end LLM-driven loop wired to a ggui MCP server
- MCP protocol reference — full
ggui_*tool signatures - GguiSession — the unit minted by each step
- Feedback Form — single-step variant of the same pattern