Skip to content

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.

  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_handshakeggui_renderggui_consume → react on intent → next step’s ggui_handshakeggui_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.

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 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 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.

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}`);