Skip to content
Sneak peek — you found hosted ggui early · official launch soon

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 — there is no close ceremony.`,
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_MCP_BEARER!}` },
},
},
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, isRenderedOutput, type GguiRenderOutput } from "@ggui-ai/protocol";
const transport = new StreamableHTTPClientTransport(new URL(process.env.GGUI_MCP_URL!), {
requestInit: { headers: { Authorization: `Bearer ${process.env.GGUI_MCP_BEARER!}` } },
});
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).
// The response leads with `outcome`; only `rendered` and `failed`
// carry a sessionId. A `refused` result carries none — the deployment
// declined before doing any work, and the handshake stays valid.
const render = await call<GguiRenderOutput>("ggui_render", {
handshakeId: hs.handshakeId,
props: prefill ? { prefill } : {},
});
if (!isRenderedOutput(render)) {
throw new Error(`ggui_render returned outcome "${render.outcome}"`);
}
const { sessionId } = render;
// 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 is simply left behind — no close call.
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 is left behind 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 left behind)
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 are simply left behind when the agent moves on — reopenable like any other render, or bounded with a per-render ttlSeconds if you want them to expire.

ggui_get_session returns the wire projection and nothing else: variant, id, appId, eventSequence, and the createdAt / lastActivityAt / expiresAt timestamps (epoch milliseconds), plus contextSnapshot — the last-known value of every contextSpec slot — when a component (render) mount’s row has one; an mcpApps mount never carries it. There is no status field and the spec fields do not travel. A driver that needs to know whether a render is still alive compares expiresAt with the clock, and reads lastActivityAt to see whether the user is still interacting.

const state = await call<{ id: string; eventSequence: number; lastActivityAt: number; expiresAt: number }>(
"ggui_get_session",
{ sessionId: company!.sessionId },
);
console.log(`Render ${state.id}: ${state.eventSequence} events, alive until ${new Date(state.expiresAt).toISOString()}`);