{"schemaVersion":1,"buildSha":"23d142e6d4ce6954c54a9130bd59de1dfb2a2828","builtAt":"2026-10-22T19:43:31.078Z","pages":[{"path":"/agentic-app-builders.md","title":"For Agentic App Builders","description":"Make your existing SaaS or webapp something an AI agent can drive — without rewriting the frontend. Vision page + waitlist for the agentic-app track, productized by guuey.","body":"---\ntitle: For Agentic App Builders\ndescription: Make your existing SaaS or webapp something an AI agent can drive — without rewriting the frontend. Vision page + waitlist for the agentic-app track, productized by guuey.\n---\n\n:::caution[Vision page]\nThis describes a direction, not a shipped product. If you're building agents **today**, take the [hosted quickstart](/quickstart/hosted-agent/) or the [self-hosted one](/quickstart/self-hosted/) — those paths are live. The waitlist at the bottom is for early access to this track when its first adapter lands.\n:::\n\n## What an agentic app is\n\nAn agentic app is a product you have already shipped, driven by an agent instead of only by hands. The user asks for the outcome — \"refund last month's invoice for this customer\" — and the agent works the real flows of your app to get there, in typed calls rather than simulated clicks.\n\nThe part that matters commercially is what it does **not** require: a frontend rewrite. Your React app, your Rails views, your existing routes and permissions all stay. Agent access arrives as a second, parallel surface onto the same flows, not as a replacement for the one your users already know.\n\nToday agents reach existing SaaS through three unreliable layers: **reading screenshots** (vision models reasoning about pixels — brittle, slow, no typed I/O), **browser automation** (Playwright scripts that snap the moment a button moves, with no way for the agent to learn the contract changed), and **official APIs** (when they exist, and only for the slice they cover — most production apps keep a large share of their behavior in the UI). What's missing between an agent and an app it did not generate is a **typed contract**.\n\n## The adapter approach\n\nYou add a GGUI adapter beside your existing app rather than inside it. The adapter reads your app's routes and describes them in the protocol's own terms: each navigable route becomes a **render** with a typed contract, form fields become **`actionSpec`** entries (typed inbound actions that drive turns), visible state becomes **`contextSpec`** (read-only state the agent observes), and browser-only libraries you depend on — Stripe Checkout, Mapbox, a calendar picker — are wrapped as **gadgets** so a model knows how to compose with them.\n\nFrom the agent's side, nothing is special about your app. It drives it through `ggui_handshake`, `ggui_render`, and `ggui_consume` exactly as it drives a freshly generated GGUI UI: same MCP wire, same contract guarantees, same failure modes. From your users' side, nothing changed at all — they keep clicking the buttons that were always there.\n\n## What you'd write\n\nThe provisional shape is a `ggui.app.json` next to your existing app config:\n\n```json\n{\n  \"name\": \"my-saas\",\n  \"routes\": [\n    {\n      \"path\": \"/invoices/new\",\n      \"intent\": \"create an invoice\",\n      \"agentCapabilities\": {\n        \"tools\": {\n          \"createInvoice\": {\n            \"toolInfo\": { \"inputSchema\": { \"$ref\": \"./schemas/invoice.json\" } }\n          }\n        }\n      },\n      \"actionSpec\": {\n        \"submit\": {\n          \"label\": \"Create invoice\",\n          \"schema\": { \"$ref\": \"./schemas/invoice.json\" },\n          \"nextStep\": \"createInvoice\"\n        }\n      },\n      \"contextSpec\": {\n        \"currentDraft\": { \"schema\": { \"$ref\": \"./schemas/invoice-draft.json\" } }\n      }\n    }\n  ]\n}\n```\n\nYour app code stays as-is. An agent runtime can now discover `/invoices/new` from the adapter's catalog, `ggui_handshake` against its contract, and `ggui_render` with a typed payload — no clicking around. (Exact wire shape for app-catalog lookup is part of the SDK's in-design surface.)\n\n## Where this is built: GGUI vs guuey\n\nThe hosted product this track becomes is **guuey**'s — a separate agent-hosting platform, at [guuey.com](https://guuey.com), built on the GGUI protocol. GGUI (this site) documents the protocol; guuey productizes hosting. guuey is not a paid tier of GGUI, and it is not run from this site.\n\nThe split is clean and worth internalizing before you plan against it:\n\n- **GGUI** (this site) is the open protocol and its hosted deployment. It defines the contract, the `ggui_*` tool surface, and the render lifecycle, and it documents both the [hosted](/quickstart/hosted-agent/) and [self-hosted](/quickstart/self-hosted/) ways to run it. The spec and the SDKs are open source.\n- **guuey** productizes hosting on top of that protocol: running your agent, the operational surface around it, and — for this track — the hosted service that keeps an adapter pointed at your live app.\n\nThe protocol side of the adapter (contracts, gadgets, the wire shape above) belongs to GGUI and will be documented here. The hosted service that runs it for you belongs to guuey and will be documented there. If a future page tells you an agentic-app feature is \"part of GGUI\", check which of those two it actually is.\n\n## Three scenarios this unlocks\n\n1. **Your support agent drives the app for the customer.** \"Refund last month's invoice for customer X\" → agent navigates to `/invoices/`, finds the row, calls `refund` with the typed payload. No human-in-the-loop browsing.\n\n2. **Your sales engineer runs an agent-narrated demo.** During a prospect call, the agent narrates while driving the form in real-time. The prospect sees the same UI any user sees, but the agent's typed actions land like a polished guided tour.\n\n3. **Power users hand work off to their AI.** \"I have 40 of these to fill in — can my AI do it?\" The AI has a typed surface to drive, not pixels to scrape.\n\n## What's shipping when\n\n| Component                                                 | Owner | Status    |\n| --------------------------------------------------------- | ----- | --------- |\n| GGUI protocol (this site)                                 | GGUI  | Live      |\n| Building a ggui-native agent                              | GGUI  | Live      |\n| Agentic App SDK (the adapter you'd add to your app)       | GGUI  | In design |\n| Hosted service that runs an adapter against your live app | guuey | In design |\n| Reference adapters for Next.js / Rails / Django           | GGUI  | Planned   |\n| Reference adapters for legacy stacks (Java EE, Drupal, …) | GGUI  | Planned   |\n\nNo dates. Real engineering work, not a marketing roadmap.\n\n## Waitlist\n\nThe adapter SDK half of this track is still in design here. The guuey platform is in open public beta at [guuey.com](https://guuey.com) — anyone can deploy a hosted agent today. If the hosted product is what you want, start there.\n\nIf it's the adapter itself you need — you'd rather run it yourself against the open protocol — drop your email below. We'll write when the first adapter ships and we have an early-access slot to fill.\n\n<form\n  class=\"waitlist-form\"\n  action=\"mailto:agentic@ggui.ai\"\n  method=\"post\"\n  enctype=\"text/plain\"\n>\n  <label for=\"waitlist-email\" class=\"waitlist-form__label\">Email</label>\n  <div class=\"waitlist-form__row\">\n    <input\n      id=\"waitlist-email\"\n      type=\"email\"\n      name=\"email\"\n      required\n      placeholder=\"you@company.com\"\n      autocomplete=\"email\"\n      class=\"waitlist-form__input\"\n    />\n    <button type=\"submit\" class=\"waitlist-form__submit\">\n      Join waitlist\n    </button>\n  </div>\n  <p class=\"waitlist-form__note\">\n    Opens your mail client with a pre-filled message. We'll wire up a real\n    intake form before the first adapter lands — until then this stays a\n    direct mailto so nothing intermediates the signal.\n  </p>\n</form>\n\n<style>\n  .waitlist-form {\n    margin: 1.5rem 0 0;\n    padding: 1.5rem;\n    border: 1px solid var(--ggui-line-2);\n    background: var(--ggui-paper-2);\n  }\n  :root[data-theme=\"dark\"] .waitlist-form {\n    border-color: var(--ggui-line-2-dark);\n    background: var(--ggui-paper-2-dark);\n  }\n  .waitlist-form__label {\n    display: block;\n    font-family: var(--sl-font-mono);\n    font-size: 10px;\n    font-weight: 600;\n    letter-spacing: 0.18em;\n    text-transform: uppercase;\n    color: var(--ggui-ink-3);\n    margin-bottom: 0.5rem;\n  }\n  :root[data-theme=\"dark\"] .waitlist-form__label {\n    color: var(--ggui-ink-3-dark);\n  }\n  .waitlist-form__row {\n    display: flex;\n    gap: 0.5rem;\n    flex-wrap: wrap;\n  }\n  .waitlist-form__input {\n    flex: 1 1 16rem;\n    min-width: 0;\n    padding: 0.625rem 0.875rem;\n    background: var(--ggui-paper);\n    border: 1px solid var(--ggui-line-2);\n    border-radius: 0;\n    font-family: inherit;\n    font-size: 14px;\n    color: var(--ggui-ink);\n  }\n  :root[data-theme=\"dark\"] .waitlist-form__input {\n    background: var(--ggui-paper-dark);\n    border-color: var(--ggui-line-2-dark);\n    color: var(--ggui-ink-dark);\n  }\n  .waitlist-form__input:focus {\n    outline: none;\n    border-color: var(--ggui-ink);\n  }\n  :root[data-theme=\"dark\"] .waitlist-form__input:focus {\n    border-color: var(--ggui-ink-dark);\n  }\n  .waitlist-form__submit {\n    padding: 0.625rem 1.25rem;\n    background: var(--ggui-ink);\n    color: var(--ggui-paper);\n    border: 1px solid var(--ggui-ink);\n    border-radius: 0;\n    font-family: var(--sl-font-mono);\n    font-size: 11px;\n    font-weight: 600;\n    letter-spacing: 0.18em;\n    text-transform: uppercase;\n    cursor: pointer;\n    transition: background 120ms ease, border-color 120ms ease;\n  }\n  .waitlist-form__submit:hover {\n    background: var(--ggui-ink-2);\n    border-color: var(--ggui-ink-2);\n  }\n  :root[data-theme=\"dark\"] .waitlist-form__submit {\n    background: var(--ggui-ink-dark);\n    color: var(--ggui-paper-dark);\n    border-color: var(--ggui-ink-dark);\n  }\n  :root[data-theme=\"dark\"] .waitlist-form__submit:hover {\n    background: var(--ggui-ink-2-dark);\n    border-color: var(--ggui-ink-2-dark);\n  }\n  .waitlist-form__note {\n    margin: 0.75rem 0 0;\n    font-size: 12px;\n    line-height: 1.55;\n    color: var(--ggui-ink-3);\n  }\n  :root[data-theme=\"dark\"] .waitlist-form__note {\n    color: var(--ggui-ink-3-dark);\n  }\n</style>\n\n## Related\n\n- [How GGUI works](/how-it-works/) — the protocol's five moments. The gguify pattern reuses the same handshake → render → interact → consume → reopen loop, just driven by your app's routes instead of LLM-generated UI.\n- [Glossary](/glossary/) — `actionSpec`, `contextSpec`, `gadget`, `tool`, `blueprint`.\n- [The dev loop](/build/dev-loop/) — how a GGUI agent moves between a local runtime and hosted ggui. The adapter track inherits the same split.\n- [For LLM agents](/agents/) — machine-readable resources, including this page at [`/agentic-app-builders.md`](/agentic-app-builders.md)."},{"path":"/agents.md","title":"For LLM agents","description":"Machine-readable resources for LLMs and coding-assistant devtools reading GGUI docs programmatically — aggregated dumps, per-page .md companions, stable anchors, wire schemas.","body":"---\ntitle: For LLM agents\ndescription: Machine-readable resources for LLMs and coding-assistant devtools reading GGUI docs programmatically — aggregated dumps, per-page .md companions, stable anchors, wire schemas.\n---\n\nThis page is for **non-human readers** — LLM agents, coding-assistant devtools (Claude Code, Cursor, Cline, Continue, Codeium), evaluators, and scrapers. Humans, the rest of the site is for you.\n\n## What's available\n\n| Resource              | URL                                      | When to fetch                                                                                                                                                                                                                                                                                                          |\n| --------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Dump pointer          | [`/llms.txt`](/llms.txt)                 | First contact. A short [llms.txt](https://llmstxt.org/) file naming the two dumps below plus 11 per-section subsets, one per top-level sidebar group. It is not a per-page list — the finest granularity it indexes is the section.                                                                                    |\n| Whole-site dump       | [`/llms-full.txt`](/llms-full.txt)       | One-shot context loading. Every page in one file, sorted to lead with Get Started, Concepts, then the hosted track — around 800 KB, roughly 200K tokens. Check `Content-Length` before you load it.                                                                                                                    |\n| Compact dump          | [`/llms-small.txt`](/llms-small.txt)     | The same page set with navigational chrome stripped. Only about 15 percent smaller than the full dump — if full does not fit, this probably will not either.                                                                                                                                                           |\n| Per-section subset    | `/_llms-txt/<slug>.txt`                  | One sidebar group at a time — `get-started`, `concepts`, `hosted`, `build`, `connect`, `self-host`, `protocol`, `api`, `cli`, `architecture`, `reference` (slugs listed in `/llms.txt`). A few pages are cross-listed in two sets where the sidebar itself repeats them (e.g. `cli/login` in both `hosted` and `cli`). |\n| Per-page raw markdown | `/<slug>.md`                             | Reading one specific page. No HTML, no chrome.                                                                                                                                                                                                                                                                         |\n| Whole-site corpus     | [`/docs-corpus.json`](/docs-corpus.json) | Every page as one JSON document — `{ schemaVersion, buildSha, builtAt, pages: [{ path, title, description, body }] }`, where each `body` is exactly the bytes that page's `.md` companion serves. Built for the docs MCP service behind `mcp.ggui.ai/docs`; fetch it once and refresh on the `Cache-Control` TTL.      |\n| Stable anchors        | `/<slug>#<id>`                           | Deep-linking to a section. Every H2/H3 has a Starlight-derived id.                                                                                                                                                                                                                                                     |\n\nBoth dumps carry the site's `404 — page not found` stub as an early section. It is navigation copy, not documentation — skip it.\n\n## Per-page `.md` companions\n\nEvery page is also served as raw markdown at the same slug with a `.md` extension. Examples:\n\n- [`/how-it-works.md`](/how-it-works.md) — the narrative walk-through\n- [`/api/mcp-protocol.md`](/api/mcp-protocol.md) — the wire reference\n- [`/protocol/envelopes.md`](/protocol/envelopes.md) — live-channel envelope shapes\n- [`/glossary.md`](/glossary.md) — terminology lookup\n- [`/cli/serve.md`](/cli/serve.md) — `ggui serve` command reference\n\nThe `.md` response is the source markdown with a small `---\\ntitle: ...\\n---` envelope on top. For the markdown-authored pages — every page except the four named below — there is no other transformation.\n\nFour pages are authored in MDX and their companions carry that through: [`/index.md`](/index.md), [`/design/tokens.md`](/design/tokens.md), [`/protocol/showcase-trimly.md`](/protocol/showcase-trimly.md) and [`/build/dev-loop.md`](/build/dev-loop.md) keep their JSX component tags (`<Card>`, `<Tabs>`, `<LinkButton>`), and `showcase-trimly` also carries the inline JS block that computes its wire counts. The ESM import preamble is stripped, so the response opens on prose, but the components are not rendered. Prose and code blocks are intact on all four; layout wrappers are not. Fetch the HTML page when you need the rendered form — on `/design/tokens.md` and `/protocol/showcase-trimly.md` in particular, much of the substance lives inside the components.\n\nFetch from any origin:\n\n```bash\ncurl https://docs.ggui.ai/protocol/envelopes.md\n```\n\n```ts\nconst res = await fetch(\"https://docs.ggui.ai/protocol/envelopes.md\");\nconst body = await res.text();\n```\n\nCORS is open (`Access-Control-Allow-Origin: *`); `Cache-Control` permits 5-minute CDN caching.\n\n## Search docs via MCP\n\nAny LLM agent can connect to `mcp.ggui.ai/docs` and search / read these docs programmatically — no auth required. See [Docs MCP route](/api/mcp-docs/) for the tool catalog and connection details.\n\n## When to use which\n\n```\nYou want                                  → Fetch\n─────────────────────────────────────────── ──────────────────────────────────\nSee which dumps and sets exist               /llms.txt\nOrient quickly, plan a session              /index.md, then /<slug>.md\nDrop everything into context (one-shot)     /llms-full.txt  (~800 KB)\nLoad just one sidebar section                /_llms-txt/<slug>.txt\nRead one specific page                      /<slug>.md\nDeep-link to a section in conversation      /<slug>/#<anchor>\n```\n\n`/llms.txt`, `/llms-full.txt`, `/llms-small.txt`, the 11 `/_llms-txt/<slug>.txt` section subsets, and the per-page `.md` companions are the whole machine-readable surface.\n\n## Stable anchors\n\nEvery H2 and H3 has a stable `id` derived by Starlight from its text. Anchors don't change between releases unless the heading text changes. Examples:\n\n- [`/glossary/#gguisession-a-render`](/glossary/#gguisession-a-render) — definition of the GguiSession (the \"render\")\n- [`/protocol/envelopes/#actionenvelope`](/protocol/envelopes/#actionenvelope) — inbound live-channel envelope\n- [`/api/mcp-protocol/#ggui_render`](/api/mcp-protocol/#ggui_render) — the `ggui_render` MCP method\n\nThe same anchors work on the `.md` companions: [`/protocol/envelopes.md#actionenvelope`](/protocol/envelopes.md#actionenvelope) — markdown clients with anchor-scroll support honor them.\n\n## Wire schemas (roadmap)\n\nThe wire envelopes (`ActionEnvelope`, `StreamEnvelope`) and the MCP method shapes (`ggui_handshake`, `ggui_render`, `ggui_consume`, …) live in TypeScript in `@ggui-ai/protocol`. Protocol version: see `PROTOCOL_VERSION` in `@ggui-ai/protocol` (currently `draft-2026-09-10`). Standalone JSON-Schema endpoints at `/api/schemas/<envelope>.json` are planned but not yet shipped.\n\nUntil then, the canonical wire shapes live at:\n\n- [`/protocol/envelopes/`](/protocol/envelopes/) — envelope shapes, fields, validation rules\n- [`/api/mcp-protocol/`](/api/mcp-protocol/) — MCP methods, request/response shapes\n- [`/api/websocket-protocol/`](/api/websocket-protocol/) — live-channel framing\n\nFor machine-parseable types, install the npm package:\n\n```bash\nnpm install @ggui-ai/protocol\n```\n\n## Site conventions\n\nThings to know if you're writing code against these docs:\n\n- **`ggui`** = the open protocol. Self-host with `ggui serve`, or use the hosted endpoint at `mcp.ggui.ai`. Open source at `github.com/ggui-ai/ggui`. Documented on this site.\n- **`guuey`** = a separate SaaS platform at `guuey.com`. Different surface, different docs. Don't conflate the two.\n- **`gadget`** = renderer-side capability — a wrapped 3rd-party library (`Leaflet`, `Stripe`, …). Formerly called `clientLibraries`.\n- **`tool`** = agent-side action (an MCP tool the agent invokes).\n- **`blueprint`** = cached UI recipe (matched at `ggui_handshake` by intent + contract similarity).\n\nThese three nouns are not interchangeable; mixing them in generated code will confuse readers.\n\n## Page-level metadata\n\nEvery page has frontmatter with at least `title` and `description`. Most also carry `audience` (one of `agent-builder`, `host`, `operator`, `llm-agent`, `agentic-app-builder`, `all`) and optionally `prereqs`. The `.md` companion re-emits `title` and `description` at the top of the response; other frontmatter fields (`audience`, `prereqs`) are only in the repo source.\n\n## Telemetry\n\nThe site sends pageview events to PostHog and tags requests from known LLM-agent user-agents (`claude-bot`, `gptbot`, `perplexitybot`, `cursor`, `cline`, `continue`, `codeium`, …) with `is_bot: true`. No PII, no fingerprinting. Set a useful user-agent and you'll show up in the bot-traffic dashboard — that helps us prioritize docs machine readers actually use.\n\n## Reporting issues\n\nFound a page that's hard for LLM consumption, or want a `.md` companion that's missing something? Open an issue at [`github.com/ggui-ai/ggui/issues`](https://github.com/ggui-ai/ggui/issues) tagged `docs/llm-readability`."},{"path":"/api/control-mcp.md","title":"Control MCP plane","description":"MCP tools on the /control plane — design-time protocol tools (anonymous) plus operator actions (apps, orgs, connector keys, coupons, blueprints, provider keys, credits) for operator agents.","body":"---\ntitle: Control MCP plane\ndescription: MCP tools on the /control plane — design-time protocol tools (anonymous) plus operator actions (apps, orgs, connector keys, coupons, blueprints, provider keys, credits) for operator agents.\n---\n\nThe `/control` plane is one of GGUI's two MCP surfaces. It carries everything called _around_ a session rather than during one:\n\n- **Design-time `protocol` tools** — format references, example blueprints, validators. Answered **anonymously**, because an agent authoring a blueprint has no account yet. See [MCP protocol](/api/mcp-protocol/) for those tools.\n- **Operator-class `ops` tools** — the same actions the [console UI](/hosted/console/) exposes to a human (create an app, rename it, mint a connector key, redeem a coupon, …), exposed as MCP tools so an LLM acting as an operator agent can perform them on the user's behalf. **Auth required**, and **confirm-gated** when state-changing.\n\nThis page is the wire reference for the 16 ops-audience handlers across four domains that ship in the open `@ggui-ai/mcp-server-handlers` package, plus the 24 hosted-only ops tools that live directly in the cloud pod's own tool registry and answer only on `mcp.ggui.ai` (see [Hosted-only ops](#hosted-only-ops) below). The agent-loop surface (handshake / render / consume / …) lives on the [`/mcp`](/api/mcp-protocol/) data plane and is documented separately — the two surfaces are strictly disjoint.\n\n:::note[Audience tags]\nEvery handler in this package carries an `audience: ['ops']` tag, which determines mounting. See [Audience Routes](/architecture/audience-routes/) for the full model — four tags, two surfaces: the `/mcp` data plane (`agent` + `runtime`) and the `/control` plane (`protocol` + `ops`). The tag is the source of truth; the surface is the projection.\n:::\n\n## What's on /control\n\nThe ops half is the destination for an **operator agent** — an LLM acting as the console's hands. Typical caller: a Claude conversation that the user opens from `console.ggui.ai` and gives natural-language instructions like \"create a new app called Inbox Triage and lock a connector key to it.\" The agent calls `ggui_ops_create_app` followed by `ggui_ops_issue_connector_key`, never touching the AppSync GraphQL layer directly.\n\nEvery tool here mirrors a UI action the console exposes. The open apps handlers in `@ggui-ai/mcp-server-handlers` are pure over typed seams (`AppsSource`, `UserDefaultAppSource`) — the cloud pod binds AppSync-backed adapters; OSS deployments leave the seams unwired and the surface stays narrow. Orgs, connector keys and coupons are hosted-only tools (see [Hosted-only ops](#hosted-only-ops)).\n\n## Endpoint\n\n```\nPOST http://127.0.0.1:6781/control\n```\n\nThe route is **always mounted** — the control plane is part of what a GGUI server is, not an opt-in. A default self-hosted boot serves the 6 design-time `protocol` tools there. The `ggui_ops_*` families register when the operator seams are wired into `createGguiServer({opsApps})` (the ops-blueprint family additionally hangs off the `opsBlueprint` dep bundle). Hosted GGUI serves the same route at `https://mcp.ggui.ai/control`, with every domain bound — the full hosted surface is 46 tools: 6 protocol + 40 ops (the 16 handler-package tools below, including provider-keys and credits, plus the 24 hosted-only tools in [Hosted-only ops](#hosted-only-ops)). See \"OSS vs hosted\" below for what a self-hosted deployment gets instead.\n\n## Authentication\n\n`/control` is **anonymous-capable**: a request with no bearer is admitted with a synthesized anonymous identity so the design-time `protocol` tools answer. Every `ggui_ops_*` handler then re-imposes auth for itself — an anonymous caller gets an auth error naming the tool, not a silent no-op and not a transport-level 401.\n\nAuthenticated callers present a bearer exactly as on [`/mcp`](/api/mcp-protocol/#authentication), via the same upstream `AuthAdapter`:\n\n```\nAuthorization: Bearer dev\nContent-Type: application/json\n```\n\nSelf-hosted: with `ggui serve --dev-allow-all`, any bearer (or none) authenticates as the `builder` identity; default serve requires a pairing-minted bearer. Hosted GGUI runs the OAuth 2.0 Dynamic Client Registration ceremony (see [OAuth on mcp.ggui.ai](/self-hosted/oauth/)). The bearer presented on `/control` is the same bearer presented on `/mcp` — there is no separate \"ops token\".\n\nExternally-federated end-user identities (`source: 'oidc'`) are rejected at the route with `403`, before MCP dispatch. The control plane is for the account holder and their operator agents, not for an app's end users.\n\n## Confirmation gate on state-changing tools\n\nAn `ops` tool that changes state answers the first call with a **preview** and mutates nothing. Only a second call carrying `confirm: true` commits:\n\n```jsonc\n// 1st call → preview, nothing happens\n{\"name\": \"ggui_ops_delete_app\", \"arguments\": {\"appId\": \"aB3kP9xY\"}}\n// → {\"confirmationRequired\": true, \"confirmationPrompt\": \"…\"}\n\n// 2nd call → commits\n{\"name\": \"ggui_ops_delete_app\", \"arguments\": {\"appId\": \"aB3kP9xY\", \"confirm\": true}}\n```\n\nThe gate exists so an agent cannot silently mint credentials, spend credits, or delete resources on the user's behalf: it forces the agent to show the human what will happen and get explicit approval. Reads and lists are single-call.\n\nClassification is **default-deny** — an `ops` tool not on the read-only list is treated as state-changing. Deployments that register their own read-only ops tools declare them via `createGguiServer({control: {singleCallOps: [...]}})`.\n\nThe single-call ops tools (`SINGLE_CALL_OPS`, 15 entries) are: `ggui_ops_list_apps`, `ggui_ops_list_orgs`, `ggui_ops_list_connector_keys`, `ggui_ops_list_blueprints`, `ggui_ops_list_my_apps`, `ggui_ops_list_my_blueprints`, `ggui_ops_list_provider_keys`, `ggui_ops_list_credit_transactions`, `ggui_ops_list_recent_renders`, `ggui_ops_get_credit_balance`, `ggui_ops_get_org_balance`, `ggui_ops_get_my_blueprint_source`, `ggui_ops_get_render_source`, `ggui_ops_setup_byok`, and `ggui_ops_save_library_blueprint`.\n\nFourteen of those are genuine reads. `ggui_ops_save_library_blueprint` is the one deliberate exception — a MUTATION added to the single-call set by owner ruling (GGUI#525, 2026-08-16). It earns the exemption on three properties: it's idempotent (same name ⇒ one row, a repeat is a no-op), it writes only to the caller's own library (no cross-tenant or account-wide effect), and its primary caller is the console helper agent auto-saving after a render, where the confirm round-trip was an agent self-confirm with no human in the loop — it bought no safety and cost an extra call on every helper render. A future single-call entry that lacks any of those three properties does not inherit this ruling on its own.\n\n## Identity model\n\nEvery handler resolves the calling identity through a single helper:\n\n```typescript\nfunction resolveOwnerSub(toolName: string, ctx: HandlerContext): string {\n  const sub = ctx.userId ?? ctx.appId;\n  if (!sub) throw new Error(`${toolName}: missing caller identity`);\n  return sub;\n}\n```\n\n- **Hosted (multi-tenant):** `ctx.userId` is the caller's Cognito sub, populated by the upstream auth adapter.\n- **OSS (single-tenant):** `ctx.userId` is undefined; `ctx.appId` (resolved by the auth adapter via `defaultAppIdFromIdentity` — typically `workspaceId ?? userId` for kind=user identities) serves as the identity.\n- **Neither set:** the handler throws — that means an unauthenticated caller slipped past auth, surfaced as a 5xx rather than masked as an empty list.\n\n### Tenancy posture\n\nEvery read and write is scoped by the resolved identity at the seam layer (`AppsSource.list(ownerSub)` returns only the caller's rows; `AppsSource.get` returns `null` for foreign rows). Cross-tenant probes never reveal whether a given id exists on another user's account — the handlers translate \"row exists but you don't own it\" to the same shape as \"no such row\":\n\n| Operation                                                                         | Cross-tenant probe                                                                           |\n| --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |\n| `ggui_ops_list_apps`                                                              | Returns only caller's rows; foreign rows invisible.                                          |\n| `ggui_ops_update_app` / `ggui_ops_set_app_theme` / `ggui_ops_set_default_app`     | Throws `app_not_found` — same as a genuinely missing id.                                     |\n| `ggui_ops_delete_app`                                                             | Returns `{deleted: true}` without touching the foreign row. Uniform with \"row didn't exist.\" |\n| `ggui_ops_rename_org` / `ggui_ops_remove_org_member` / `ggui_ops_get_org_balance` | Throws `org_not_found` for orgs the caller isn't a member of — same as a missing id.         |\n| `ggui_ops_invite_to_org` / `ggui_ops_revoke_invite`                               | Throws `org_invite_access_denied` for orgs the caller doesn't administer.                    |\n| `ggui_ops_revoke_connector_key`                                                   | Throws `connector_key_access_denied` for keys owned by other users.                          |\n| `ggui_ops_redeem_coupon`                                                          | Throws `coupon_access_denied` for `targetOrgId` orgs the caller isn't a member of.           |\n| `ggui_ops_set_provider_key` / `ggui_ops_remove_provider_key` with `appId`         | Throws `app_not_found` for apps the caller doesn't own — same as a missing id.               |\n\n### One-time secret reveal\n\n:::caution[Plaintext keys appear exactly once]\n`ggui_ops_issue_connector_key` returns the plaintext `ggui_user_*` secret on its result — this is the **only call** that ever surfaces it. The adapter persists `sha256(plaintext)` hex plus the first ~8 plaintext characters (`apiKeyPrefix`); the plaintext is not stored anywhere. Subsequent `ggui_ops_list_connector_keys` responses carry the prefix and the metadata but never the full secret.\n\nThe MCP caller (Claude Desktop conversation, console) is responsible for surfacing the plaintext to the user immediately. There is no recovery if it's lost — the user must revoke and reissue.\n:::\n\n---\n\n## Tools by domain\n\nFour domains, 16 handlers total. Each domain is optional: apps hangs off `CreateGguiServerOptions.opsApps`; ops-blueprint hangs off the `opsBlueprint` dep bundle on `defaultHandlers`; provider-keys and credits are bound by the hosted cloud pod. Leaving a domain unwired removes its tools from `tools/list` at registration time.\n\n## Apps (`ops-apps`, 6 handlers)\n\nOperator actions on `GguiApp` rows — the rows the universal MCP route resolves per-request to scope sessions. Each row carries `appId` (server-minted base62), `displayName`, optional `systemPrompt` override, optional `rateLimitPerMinute`, `createdAt`, `updatedAt`. Bound on the cloud pod via DDB adapters over the `GguiApp` table.\n\n### `ggui_ops_list_apps`\n\nEnumerate every `GguiApp` row owned by the calling user. Returns metadata only — same data the console's Apps section renders. Use to discover ids before calling the mutating tools.\n\n**Inputs:** none.\n\n**Returns:** `{ apps: AppRecord[] }`\n\n```typescript\ninterface AppRecord {\n  readonly appId: string;\n  readonly displayName: string;\n  readonly systemPrompt?: string;\n  readonly createdAt: string;\n  readonly updatedAt: string;\n}\n```\n\n**Tenancy:** scope is `ownerSub` from the bearer token. Cross-user listings are impossible by construction.\n\n### `ggui_ops_create_app`\n\nProvision a fresh `GguiApp` owned by the calling user. Wraps the cloud's `provisionGguiApp` mutation — opaque base62 `appId` is minted server-side; argument-supplied `appId` is NEVER honored (tenant-takeover vector).\n\n| Field         | Type                   | Required | Description                                                                                                    |\n| ------------- | ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |\n| `displayName` | `string` (1–120 chars) | No       | Human-friendly label. Defaults to `'My GGUI app'` when absent — matches the auto-create path in `useGguiUser`. |\n\n**Returns:** the full `AppRecord` shape as above.\n\n**Follow-up:** call `ggui_ops_set_default_app({appId})` to promote the new app to the user's default.\n\n### `ggui_ops_update_app`\n\nPartially update an app the caller owns — `displayName`, `systemPrompt`, and `rateLimitPerMinute` in one call. At least one field is required; an empty update is rejected before any store work.\n\n| Field                | Type                     | Required | Description                                                                                                                               |\n| -------------------- | ------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |\n| `appId`              | `string`                 | Yes      | Target `GguiApp.appId`. Discover via `ggui_ops_list_apps`.                                                                                |\n| `displayName`        | `string` (1–120 chars)   | No       | New display name.                                                                                                                         |\n| `systemPrompt`       | `string` (≤10,000 chars) | No       | Replacement text. Pass `\"\"` to clear the override — renders then fall back to the universal default.                                      |\n| `rateLimitPerMinute` | `integer` (≥ 0)          | No       | Per-API-key renders/minute. Pass `0` to clear the limit (unlimited). The stored column keeps ONE \"unlimited\" representation, server-side. |\n\n**Returns:** the updated `AppRecord` (with `systemPrompt` / `rateLimitPerMinute` omitted when cleared).\n\n**Errors:**\n\n| Code            | When                                                                                     |\n| --------------- | ---------------------------------------------------------------------------------------- |\n| `app_not_found` | The id doesn't exist OR exists under another tenant (uniform shape — no existence leak). |\n\n### `ggui_ops_delete_app`\n\nHard-delete an app owned by the calling user. Idempotent — a second delete of the same id resolves cleanly.\n\n| Field   | Type     | Required | Description             |\n| ------- | -------- | -------- | ----------------------- |\n| `appId` | `string` | Yes      | Target `GguiApp.appId`. |\n\n**Returns:** `{ deleted: true }`\n\n**Tenancy:** cross-tenant probes return the success shape without touching the foreign row. Uniform with \"row didn't exist.\"\n\n**Errors:**\n\n| Code                         | When                                                                                                                                                                                       |\n| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `default_app_delete_blocked` | The target is the caller's default app. The universal route resolves `defaultAppId` on every request, so it must point at a live app — call `ggui_ops_set_default_app` first, then delete. |\n\n:::note[Cleanup is asynchronous]\n`{deleted: true}` means the `GguiApp` row is gone and the dependent-row sweep is queued. Both delete paths — this tool and the console's **Apps → row menu → Delete** — converge on one cascade worker that revokes the app's keys and removes its blueprints, provider keys, installs, tool-identity catalog, durable registry rows, and render-identity records; through this tool the sweep is eventually consistent (typically seconds). Every delete emits a structured `app_delete_rows_orphaned` log event naming the row classes pending cleanup, with `cascadeQueued` saying whether the sweep was durably queued ([#463](https://github.com/loqu-co/ggui/issues/463)).\n:::\n\n### `ggui_ops_set_default_app`\n\nSet the calling user's `GguiUser.defaultAppId` — the universal MCP route resolves this on every request to scope the session. The handler first verifies the caller owns the target `appId` before writing `User.defaultAppId`.\n\n| Field   | Type     | Required | Description                               |\n| ------- | -------- | -------- | ----------------------------------------- |\n| `appId` | `string` | Yes      | Target app — must be owned by the caller. |\n\n**Returns:** `{ defaultAppId: string }`\n\n**Errors:**\n\n| Code            | When                                                        |\n| --------------- | ----------------------------------------------------------- |\n| `app_not_found` | Target `appId` doesn't exist OR is owned by another tenant. |\n\n### `ggui_ops_set_app_theme`\n\nReplace the theme on an app the caller owns. The payload is validated with the protocol's canonical `appThemeSchema` — the same validator every `GguiApp.theme` write surface runs — so no surface can persist a theme another surface would reject.\n\n| Field   | Type       | Required | Description                                                                                                                                            |\n| ------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `appId` | `string`   | Yes      | Target `GguiApp.appId`.                                                                                                                                |\n| `theme` | `AppTheme` | Yes      | `{mode: 'light'\\|'dark', cssVariables, name?}`. Keys must match `--ggui-*`; values ≤256 chars with breakout characters rejected; ≤200 variables total. |\n\n**Returns:** `{ appId, theme, updatedAt }` — the persisted theme echoed back.\n\n**Errors:**\n\n| Code            | When                                                        |\n| --------------- | ----------------------------------------------------------- |\n| `app_not_found` | Target `appId` doesn't exist OR is owned by another tenant. |\n\n---\n\n## Blueprints (`ops-blueprint`, 5 handlers)\n\nOperator blueprint authorship — generate, register, list, update, delete cached blueprints for the calling app. Unlike the apps domain, this family registers on the OSS server via the `opsBlueprint` dep bundle on `defaultHandlers` (registry + blueprint store + search; `generate` additionally requires the `resolveLlm` + `blueprints` deps the render generation path reads).\n\n### `ggui_ops_generate_blueprint`\n\nAuthor a blueprint via the bound generator (LLM generation + validation).\n\n| Field                  | Type      | Required | Description                                                                                                                                                                                                                                            |\n| ---------------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `appId`                | `string`  | No       | Target app. Defaults to the caller's bound app; a caller not bound to an app must pass it.                                                                                                                                                             |\n| `contract`             | `object`  | Yes      | The `DataContract` to generate against.                                                                                                                                                                                                                |\n| `generator`            | `string`  | No       | Generator slug. Unknown slug fails with `generator_not_found`.                                                                                                                                                                                         |\n| `persona`              | `string`  | No       | Variance axis — normalized lowercase + trimmed.                                                                                                                                                                                                        |\n| `aesthetic`            | `string`  | No       | Variance axis.                                                                                                                                                                                                                                         |\n| `context`              | `object`  | No       | Variance axis: a small structured signal.                                                                                                                                                                                                              |\n| `seedPrompt`           | `string`  | No       | Variance axis.                                                                                                                                                                                                                                         |\n| `intent`               | `string`  | No       | What to generate, in plain words: the prompt the generator composes from, at most 2,000 characters. Not part of the cache identity, so calls that differ only in `intent` share a variant key. When omitted, the generator falls back to `seedPrompt`. |\n| `setAsOperatorDefault` | `boolean` | No       | Mark the result as the operator default for its contract (orders listings; does not decide what serves).                                                                                                                                               |\n\n**Returns:** `{ blueprintId, codeHash?, source }` — `source` is the stamped provenance `{ kind: 'llm', generator, model }` from the engine's own metadata stamp.\n\n**Errors:** `generator_not_found`; `missing_credentials` (BYOK fix: `ggui_ops_set_provider_key`); generation failure.\n\n### `ggui_ops_register_blueprint`\n\nRegister pre-built component code verbatim — no LLM, no validator. Operator entry point for fixture seeding and export/reimport round-trips.\n\n| Field           | Type     | Required | Description                                           |\n| --------------- | -------- | -------- | ----------------------------------------------------- |\n| `contract`      | `object` | Yes      | The `DataContract` the code implements.               |\n| `componentCode` | `string` | Yes      | The component code to register verbatim (min 1 char). |\n\nPlus the same optional `generator` / `persona` / `aesthetic` / `context` / `seedPrompt` / `setAsOperatorDefault` fields as `ggui_ops_generate_blueprint`, and four optional non-identity facts the semantic reuse pre-filter reads off the cache row (none is part of the cache identity; an omitted fact reads _not evaluated_, never a miss):\n\n| Field             | Type                               | Required | Description                                                                                                                                                                                                                                                                                                                                  |\n| ----------------- | ---------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `intent`          | `string`                           | No       | What the interface is for, in the request's own words — the sentence the semantic reuse judge compares. Falls back to `seedPrompt`, then `persona`.                                                                                                                                                                                          |\n| `judgedCanvases`  | `string[]`                         | No       | A non-empty list of the canvas classes the interface was judged on (e.g. `xs-chat-card`, `md`). A request on a canvas outside the list is not offered it for semantic reuse.                                                                                                                                                                 |\n| `aestheticPreset` | `{ id: string, version?: string }` | No       | The aesthetic preset (and resolved version, when known) it was generated under. A request under another preset, or a pinned version that differs, is not offered it.                                                                                                                                                                         |\n| `directionDigest` | `string` (sha256 hex)              | No       | Digest of the direction text it was generated under, normalised (trimmed, whitespace collapsed, lowercased). A request whose direction digests differently is not offered it.                                                                                                                                                                |\n| `directionScope`  | `'app' \\| 'request'`               | No       | Which direction `directionDigest` hashes: `app` — the direction the app's profile carries; `request` — a direction given with the request itself. Only beside `directionDigest` (refused without it).                                                                                                                                        |\n| `clonedFrom`      | `string`                           | No       | When these bytes are a copy of a row this app already holds, that row's `blueprintId`. The new row records it as provenance; nothing that serves reads it. Refused (`cloned_from_refused`, nothing persisted) when empty, the new row's own id, not held, or another app's row, with the same answer for a missing row as for another app's. |\n\n**Returns:** `{ blueprintId, codeHash, source }` — `source` is always `{ kind: 'user' }`; hand-supplied bytes carry no engine claim, so none is recorded.\n\n### `ggui_ops_list_blueprints`\n\n| Field            | Type       | Required | Description                                           |\n| ---------------- | ---------- | -------- | ----------------------------------------------------- |\n| `contractHash`   | `string`   | No       | Filter by canonical contract hash.                    |\n| `generator`      | `string`   | No       | Filter by generator slug.                             |\n| `persona`        | `string`   | No       | Dispatches semantic search.                           |\n| `intentKeywords` | `string[]` | No       | Dispatches semantic search. Filters are AND-composed. |\n\n**Returns:** `{ blueprints: Blueprint[] }`\n\nA row minted by a generation carries `build`: the minting engine's version, design mode and template digests. Registered, installed and older rows carry none, and a reader treats that as an unknown build.\n\n### `ggui_ops_update_blueprint`\n\n| Field               | Type           | Required | Description                                                                  |\n| ------------------- | -------------- | -------- | ---------------------------------------------------------------------------- |\n| `blueprintId`       | `string`       | Yes      | Target blueprint.                                                            |\n| `isOperatorDefault` | `literal true` | No       | Mark as the operator default (orders listings; does not decide what serves). |\n| `variance`          | `object`       | No       | Partial-merge of variance axes; `{persona: \"\"}` clears the field.            |\n\n**Returns:** `{ blueprintId, updatedAt }`\n\n### `ggui_ops_delete_blueprint`\n\n| Field         | Type     | Required | Description       |\n| ------------- | -------- | -------- | ----------------- |\n| `blueprintId` | `string` | Yes      | Target blueprint. |\n\n**Returns:** `{ deleted: true }` — idempotent.\n\n---\n\n## Provider keys (`provider-keys`, 3 handlers) — BYOK\n\nOperator actions on the caller's BYOK LLM provider keys. Provider enum: `'anthropic' | 'openai' | 'google' | 'openrouter'`. The handler factories ship in `@ggui-ai/mcp-server-handlers`; they are bound today by the hosted cloud pod, which validates keys against the provider and encrypts at rest.\n\n### `ggui_ops_set_provider_key`\n\n| Field          | Type     | Required | Description                                                                                                                                                                                                                                                              |\n| -------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `provider`     | `enum`   | Yes      | One of `anthropic` / `openai` / `google` / `openrouter`.                                                                                                                                                                                                                 |\n| `plaintextKey` | `string` | Yes      | The provider API key (min 1 char). Re-set replaces (rotation).                                                                                                                                                                                                           |\n| `label`        | `string` | No       | Human label.                                                                                                                                                                                                                                                             |\n| `appId`        | `string` | No       | Scope the key to ONE app the caller owns instead of the whole account. App key beats account key for that app's renders. Requires app-scoped key storage; deployments without it reject with `app_scoped_keys_unavailable`. Foreign/missing apps answer `app_not_found`. |\n\n**Returns:** `{ provider, label?, lastFour, createdAt?, lastUsedAt? }` — never echoes the key.\n\n### `ggui_ops_list_provider_keys`\n\n**Inputs:** none.\n\n**Returns:** `{ keys: [{ provider, label?, lastFour, createdAt?, lastUsedAt? }] }`\n\n### `ggui_ops_remove_provider_key`\n\n| Field      | Type     | Required | Description                                                                                                                                             |\n| ---------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `provider` | `enum`   | Yes      | Provider to remove.                                                                                                                                     |\n| `appId`    | `string` | No       | Remove the key scoped to one app the caller owns instead of the account-level key. Same availability + tenancy rules as on `ggui_ops_set_provider_key`. |\n\n**Returns:** `{ deleted, provider }`\n\n---\n\n## Credits (`credits`, 2 handlers)\n\nRead-only views over the caller's prepaid credit wallet. Bound by the hosted cloud pod; self-hosted deployments have no credit plane.\n\n### `ggui_ops_get_credit_balance`\n\n**Inputs:** none.\n\n**Returns:** `{ balanceCents, lifetimeGrantedCents, lifetimeSpentCents, updatedAt }`\n\n### `ggui_ops_list_credit_transactions`\n\n| Field    | Type     | Required | Description        |\n| -------- | -------- | -------- | ------------------ |\n| `limit`  | `number` | No       | 1–100, default 20. |\n| `cursor` | `string` | No       | Pagination cursor. |\n\n**Returns:** `{ transactions: [{ transactionId, kind, deltaCents, balanceAfterCents, reason, createdAt, relatedSessionId? }], nextCursor? }` — `kind` is an open vocabulary: the known kinds are `free_credit`, `render_charge`, `topup` and `refund`, and a deployment may add others. Accept a kind you do not know, keep it as named, and never read it as a render charge.\n\n---\n\n## Hosted-only ops\n\nTwenty-four more `ops`-audience tools live directly in `cloud/ggui-protocol-pod/src/tools/` — the cloud pod's own tool registry, not the open `@ggui-ai/mcp-server-handlers` package. They answer only on `https://mcp.ggui.ai/control`; a self-hosted `ggui serve` never registers them, because there is no seam to wire them through — they read and write cloud-pod-native tables directly. Thirteen back the chat-native experience (a Claude Desktop or claude.ai conversation acting as the user's own agent) rather than a specific console UI action, so they're absent from the [Console parity](#console-parity) table below. The other eleven — orgs, connector keys and coupons — mirror console actions and are listed there; they shipped in the open handler package before v0.25.0.\n\n### App meta-tools\n\nUniversal connector key only (a per-app key is scoped to one app and structurally can't enumerate or switch siblings).\n\n#### `ggui_ops_list_my_apps`\n\nEnumerate the caller's apps, most-recently-touched first.\n\n**Inputs:** `limit` (`integer`, 1–500, optional, default 100).\n\n**Returns:** `{ apps: [{ appId, displayName, isDefault, createdAt, updatedAt }] }` — `isDefault` marks the app the universal `mcp.ggui.ai` endpoint currently resolves to.\n\n#### `ggui_ops_open_app`\n\nSwitch the caller's default app. Writes `User.defaultAppId`; takes effect on the **next** connection — the calling Claude session keeps its already-bound `appId` for the life of the connection.\n\n| Field   | Type     | Required | Description                                                                                     |\n| ------- | -------- | -------- | ----------------------------------------------------------------------------------------------- |\n| `appId` | `string` | Yes      | 8-char base62 target app id. Must be owned by the caller. Discover via `ggui_ops_list_my_apps`. |\n\n**Returns:** `{ appId, message }` — `message` reiterates the reconnect-to-switch caveat.\n\n### Personal blueprint library\n\nThe caller's own saved-blueprint library (Claude composes something useful in a conversation, saves it, renders it again later) — a different `GguiUserBlueprint` world from the `ops-blueprint` family's app-scoped variant curation above, scoped per-app via `ctx.appId` rather than per-owner.\n\n#### `ggui_ops_list_my_blueprints`\n\n| Field   | Type      | Required | Description         |\n| ------- | --------- | -------- | ------------------- |\n| `limit` | `integer` | No       | 1–500, default 100. |\n\n**Returns:** `{ blueprints: [{ name, description?, tags?, usageCount, createdAt, updatedAt, lastUsedAt?, systemSourceId?, systemVersion? }] }` — definition bodies are omitted to keep the list lean; fetch one with `ggui_ops_get_my_blueprint_source`.\n\n#### `ggui_ops_get_my_blueprint_source`\n\n| Field  | Type     | Required | Description                                                 |\n| ------ | -------- | -------- | ----------------------------------------------------------- |\n| `name` | `string` | Yes      | Blueprint slug. Discover via `ggui_ops_list_my_blueprints`. |\n\n**Returns:** `{ name, definition: { source, contract?, fixtureProps? }, description?, tags?, updatedAt }`.\n\n**Errors:** `blueprint_not_found` (missing or foreign name — uniform, no existence leak); `blueprint_definition_invalid` (a stored row lost its source text).\n\n#### `ggui_ops_save_library_blueprint`\n\nUpsert a blueprint into the caller's library, keyed on `(appId, name)` — same name replaces the existing row. Re-runs the canonical D16 validator (compile → self-check → runtime probe) server-side as defense-in-depth. Single-call by owner ruling; see the [confirmation-gate section](#confirmation-gate-on-state-changing-tools) above for why.\n\n| Field         | Type       | Required | Description                                                                                                                        |\n| ------------- | ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |\n| `name`        | `string`   | Yes      | Slug 2–64 chars, kebab-case `[a-z0-9-]`, no underscores, no single-char names.                                                     |\n| `blueprint`   | `object`   | Yes      | `{source, contract?, fixtureProps?}` — pass the object as-is, e.g. straight from `ggui_ops_get_render_source`'s `blueprint` field. |\n| `description` | `string`   | No       | Surfaces in the console's miniapp library.                                                                                         |\n| `tags`        | `string[]` | No       | Organization tags.                                                                                                                 |\n\n**Returns:** `{ name, createdAt, updatedAt }`.\n\n**Errors:** the D16 validator's tiered failure envelope (same shape as `ggui_protocol_validate_blueprint`) when validation fails.\n\n#### `ggui_ops_update_my_blueprint`\n\nRename a library blueprint and/or replace its `description` / `tags` in one call — at least one of `newName`, `description`, `tags` is required. Rename applies first and refuses to overwrite an existing sibling name.\n\n| Field         | Type       | Required | Description                                                                              |\n| ------------- | ---------- | -------- | ---------------------------------------------------------------------------------------- |\n| `name`        | `string`   | Yes      | Current slug. Discover via `ggui_ops_list_my_blueprints`.                                |\n| `newName`     | `string`   | No       | New slug, same rule as `name`. Same-name is a no-op.                                     |\n| `description` | `string`   | No       | Replacement (≤4000 chars); empty string clears it.                                       |\n| `tags`        | `string[]` | No       | Replacement list, trimmed/lowercased/deduped, ≤32 tags of ≤64 chars; empty array clears. |\n\n**Returns:** `{ name, updatedAt }` — `name` is the post-rename value.\n\n**Errors:** `blueprint_not_found`; `blueprint_name_taken` (rename collision, names the taken slug).\n\n#### `ggui_ops_delete_my_blueprint`\n\nIdempotent delete by name.\n\n| Field  | Type     | Required | Description                                                 |\n| ------ | -------- | -------- | ----------------------------------------------------------- |\n| `name` | `string` | Yes      | Blueprint slug. Discover via `ggui_ops_list_my_blueprints`. |\n\n**Returns:** `{ name, deleted }` — `deleted: false` means no such blueprint existed; still a success.\n\n### Render introspection\n\nUniversal connector key only — same posture as the app meta-tools, since both read across every app the caller owns.\n\n#### `ggui_ops_list_recent_renders`\n\n| Field         | Type      | Required | Description                                                                                    |\n| ------------- | --------- | -------- | ---------------------------------------------------------------------------------------------- |\n| `limit`       | `integer` | No       | 1–100, default 20.                                                                             |\n| `appId`       | `string`  | No       | Narrow to one app. Omit to list across every app the caller owns.                              |\n| `erroredOnly` | `boolean` | No       | Return only renders whose generation failed. Covers at most the 500 most-recent rows per call. |\n\n**Returns:** `{ renders: [{ sessionId, appId, createdAt, status, errorCode? }] }` — `status` is `active` or `expired`; an empty list is a valid \"nothing yet\" answer, not an error.\n\n#### `ggui_ops_get_render_source`\n\nCaller-gated source read for a render session — the link between a render and `ggui_ops_save_library_blueprint`. Only component-variant renders have source; MCP-Apps and system-card renders (and a render that hasn't finished its first commit) answer a typed no-source error instead of an empty string.\n\n| Field       | Type     | Required | Description                                                    |\n| ----------- | -------- | -------- | -------------------------------------------------------------- |\n| `sessionId` | `string` | Yes      | From `ggui_ops_list_recent_renders` or a `ggui_render` result. |\n\n**Returns:** `{ sessionId, blueprint: { source, contract?, fixtureProps? } }` — feed `blueprint` straight into `ggui_ops_save_library_blueprint`'s `blueprint` argument with no reshaping.\n\n**Errors:** `render_not_found` (missing or foreign session — uniform, no existence leak).\n\n### BYOK setup card\n\n#### `ggui_ops_setup_byok`\n\nEntry point for the BYOK provider-key MCP App card — the chat-native counterpart to `ggui_ops_list_provider_keys` above, shaped for inline card rendering rather than flat consumption. Declares `_meta.ui.resourceUri` so MCP-Apps-aware hosts pre-fetch the bundle and render the card on first call.\n\n**Inputs:** none.\n\n**Returns:** `{ kind: 'provider-key', configured: [{ provider, prefix }] }` — `prefix` is a display string like `sk-ant…abcd` (canonical provider prefix + the stored last-four), never the plaintext key.\n\n### Registry scope administration\n\nPlatform-operator only — gated by `requireScopeOpsOperator` against the `REGISTRY_OPS_OPERATOR_SUBS` allowlist (checked against `ctx.userId`), on top of the normal auth + confirm gates. Scope **owners** publish through the ordinary publish flow and never call these; they exist for the registry operator to reassign or verify scope ownership (e.g. reclaiming a squatted `@scope` for its proven brand owner) and to record which signer identities may publish under a scope. All three mutate global registry state, are deliberately excluded from the single-call set, and emit a structured audit line (`scope_transfer` / `scope_verification_set` / `scope_san_allowlist_set` / `scope_ops_denied`) on every call.\n\n#### `ggui_ops_scope_transfer`\n\nReassign an existing scope's ownership row, or seed one for a reserved scope that was never first-publish-claimable. The written row always lands `verification: 'unverified'` — a transfer moves ownership, it does not assert proof.\n\n| Field             | Type     | Required | Description                                               |\n| ----------------- | -------- | -------- | --------------------------------------------------------- |\n| `scope`           | `string` | Yes      | Target scope, leading `@` included.                       |\n| `newOwnerSubject` | `string` | Yes      | Auth subject that will own publishes under the scope.     |\n| `reason`          | `string` | Yes      | Why ownership moves — recorded verbatim in the audit log. |\n\n**Returns:** `{ scope, ownerSubject, claimedAt, verification, previousOwnerSubject? }`.\n\n#### `ggui_ops_scope_set_verification`\n\nRecord or withdraw the verified-domain proof on an existing ownership row. `verified` requires `verifiedDomain`; `unverified` forbids it (withdrawing clears both proof fields).\n\n| Field            | Type                         | Required    | Description                                                                     |\n| ---------------- | ---------------------------- | ----------- | ------------------------------------------------------------------------------- |\n| `scope`          | `string`                     | Yes         | Must already have an ownership row — seed with `ggui_ops_scope_transfer` first. |\n| `verification`   | `'unverified' \\| 'verified'` | Yes         | New label.                                                                      |\n| `verifiedDomain` | `string`                     | Conditional | Required with `verified`; forbidden with `unverified`.                          |\n\n**Returns:** `{ scope, ownerSubject, claimedAt, verification, verifiedDomain?, verifiedAt? }`.\n\n#### `ggui_ops_scope_set_san_allowlist`\n\nSet or clear the exact sigstore certificate identities (emails or CI workflow URIs) permitted to publish under a scope — pass exactly one of `sanAllowlist` (non-empty, to set) or `clear: true` (to remove); an empty array is refused rather than guessed at. Scopes without a list fall back to the deployment's default identity rule (verified-email match, where wired).\n\n| Field          | Type       | Required | Description                                                                                         |\n| -------------- | ---------- | -------- | --------------------------------------------------------------------------------------------------- |\n| `scope`        | `string`   | Yes      | Must already have an ownership row — seed with `ggui_ops_scope_transfer` first.                     |\n| `sanAllowlist` | `string[]` | No       | Replacement allowlist, exact literal strings only (no regex/glob). Mutually exclusive with `clear`. |\n| `clear`        | `boolean`  | No       | Remove the allowlist. Mutually exclusive with `sanAllowlist`.                                       |\n\n**Returns:** `{ scope, ownerSubject, claimedAt, verification, verifiedDomain?, verifiedAt?, sanAllowlist? }` — `sanAllowlist` absent after a clear.\n\n### Orgs (7 tools)\n\nOperator actions on `GguiOrg` + `GguiOrgMember` + `GguiOrgInvite` (+ the org credit-balance) rows. Orgs are the unit of multi-user collaboration; each row carries `orgId` (ULID), `name`, `ownerUserId`, plus per-membership role on the join rows. Bound on the cloud pod via DDB adapters over the same tables the console's org mutations write.\n\n#### `ggui_ops_list_orgs`\n\nEnumerate every org the calling user belongs to — owner + admin + member memberships in a single list, each row carrying the caller's role.\n\n**Inputs:** none.\n\n**Returns:** `{ orgs: OrgMembershipRecord[] }`\n\n```typescript\ninterface OrgMembershipRecord {\n  readonly orgId: string;\n  readonly name: string;\n  readonly ownerUserId: string;\n  readonly role: \"owner\" | \"admin\" | \"member\";\n  readonly joinedAt: string;\n}\n```\n\nMirrors the AppSync `fetchMyOrgs` custom resolver. Use to discover `orgId` before calling the invite tools.\n\n#### `ggui_ops_create_org`\n\nProvision a fresh `GguiOrg` owned by the calling user. Wraps the cloud's `provisionGguiOrg` mutation — ULID `orgId` minted server-side; an owner membership row and a zero-balance credit row are inserted atomically via TransactWrite.\n\n| Field  | Type                   | Required | Description                                                                          |\n| ------ | ---------------------- | -------- | ------------------------------------------------------------------------------------ |\n| `name` | `string` (1–120 chars) | Yes      | Human-friendly display name. Required (no default — orgs are intentional creations). |\n\n**Returns:**\n\n```typescript\ninterface CreateOrgOutput {\n  readonly orgId: string;\n  readonly name: string;\n  readonly ownerUserId: string;\n  readonly createdAt: string;\n  readonly updatedAt: string;\n}\n```\n\n#### `ggui_ops_rename_org`\n\nRename an org the caller owns or administers. Authorization is owner-OR-admin — an admin curates the org's presentation just like the owner; member-role callers are rejected.\n\n| Field   | Type                   | Required | Description                                                                       |\n| ------- | ---------------------- | -------- | --------------------------------------------------------------------------------- |\n| `orgId` | `string`               | Yes      | Target org — caller must own or administer it. Discover via `ggui_ops_list_orgs`. |\n| `name`  | `string` (1–120 chars) | Yes      | New display name. Trimmed; cap matches org provisioning.                          |\n\n**Returns:** `{ orgId, name, updatedAt }`\n\n**Errors:**\n\n| Code                | When                                                                             |\n| ------------------- | -------------------------------------------------------------------------------- |\n| `org_not_found`     | The id doesn't exist OR the caller isn't a member (uniform — no existence leak). |\n| `org_access_denied` | The caller is a member but not owner/admin.                                      |\n\n#### `ggui_ops_remove_org_member`\n\nRemove a member from an org, or leave an org by removing yourself. Permission matrix (caller's role × target's role):\n\n|                 | target=owner | target=admin | target=member |\n| --------------- | ------------ | ------------ | ------------- |\n| `caller=owner`  | no           | yes          | yes           |\n| `caller=admin`  | no           | only-self    | yes           |\n| `caller=member` | no           | no           | only-self     |\n\nThe org **owner can never be removed** — ownership transfer is a separate flow. Matrix violations surface the specific rule that was hit; only non-member callers get the uniform not-found.\n\n| Field          | Type     | Required | Description                                               |\n| -------------- | -------- | -------- | --------------------------------------------------------- |\n| `orgId`        | `string` | Yes      | Target org — the caller must be a member.                 |\n| `memberUserId` | `string` | Yes      | Member to remove. Pass your own user id to leave the org. |\n\n**Returns:** `{ orgId, memberUserId, alreadyAbsent }` — `alreadyAbsent: true` means no membership row existed (idempotent; a parallel removal is not an error).\n\n**Errors:**\n\n| Code                        | When                                                                             |\n| --------------------------- | -------------------------------------------------------------------------------- |\n| `org_not_found`             | The id doesn't exist OR the caller isn't a member (uniform — no existence leak). |\n| `org_member_removal_denied` | The role matrix denies the removal — the message names the specific rule.        |\n\n#### `ggui_ops_get_org_balance`\n\nRead the shared prepaid credit balance of an org the caller belongs to. **Any membership role** — every member can see how much shared credit there is to spend. Single-call (no confirmation gate).\n\n| Field   | Type     | Required | Description                               |\n| ------- | -------- | -------- | ----------------------------------------- |\n| `orgId` | `string` | Yes      | Target org — the caller must be a member. |\n\n**Returns:** `{ orgId, balanceCents, lifetimeGrantedCents, lifetimeSpentCents, updatedAt }` — an org with no spend history reads as zeros (the balance value is the contract, not the row's existence).\n\n**Errors:**\n\n| Code            | When                                                                             |\n| --------------- | -------------------------------------------------------------------------------- |\n| `org_not_found` | The id doesn't exist OR the caller isn't a member (uniform — no existence leak). |\n\n#### `ggui_ops_invite_to_org`\n\nIssue an `admin`- or `member`-role invite to a `GguiOrg` the caller can administer. The invite link in the recipient's email points at the console: `console.ggui.ai/invites/<inviteId>`.\n\n| Field   | Type                  | Required | Description                                                                                                           |\n| ------- | --------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- |\n| `orgId` | `string`              | Yes      | Target org — caller must own or administer it. Discover via `ggui_ops_list_orgs`.                                     |\n| `email` | `string` (RFC 5322)   | Yes      | Recipient email — the invite link is sent here.                                                                       |\n| `role`  | `'admin' \\| 'member'` | Yes      | Role the recipient holds once they accept. Owner can't be granted via invite — ownership transfer is a separate flow. |\n\n**Returns:**\n\n```typescript\ninterface InviteToOrgOutput {\n  readonly inviteId: string;\n  readonly orgId: string;\n  readonly email: string;\n  readonly role: \"admin\" | \"member\";\n  readonly inviterUserId: string;\n  readonly status: \"pending\" | \"accepted\" | \"revoked\" | \"expired\";\n  readonly expiresAt: string;\n  readonly createdAt: string;\n  readonly reused: boolean;\n}\n```\n\n**Anti-double-issue:** an existing pending invite for the same `(orgId, email)` is reused — no new row, no second email. `reused: true` flags the dedup.\n\n**Errors:**\n\n| Code                       | When                                         |\n| -------------------------- | -------------------------------------------- |\n| `org_invite_access_denied` | Caller is not owner/admin of the target org. |\n\n#### `ggui_ops_revoke_invite`\n\nInvalidate a pending org invite — the bearer-secret link in the recipient's email stops working immediately.\n\n| Field      | Type     | Required | Description                                                      |\n| ---------- | -------- | -------- | ---------------------------------------------------------------- |\n| `inviteId` | `string` | Yes      | Target invite — must belong to an org the caller can administer. |\n\n**Returns:**\n\n```typescript\ninterface RevokeInviteOutput {\n  readonly inviteId: string;\n  readonly status: \"pending\" | \"accepted\" | \"revoked\" | \"expired\";\n  readonly alreadyRevoked: boolean;\n}\n```\n\n**Concurrency:** the adapter flips `status` from `pending` → `revoked` via a CAS `ConditionExpression`. A racing accept surfaces a clear conflict instead of silently overwriting. Already-revoked invites return `alreadyRevoked: true`; already-accepted invites reject.\n\n**Errors:**\n\n| Code                       | When                                                   |\n| -------------------------- | ------------------------------------------------------ |\n| `org_invite_access_denied` | Caller is not owner/admin of the invite's org.         |\n| `org_invite_not_found`     | The id doesn't exist OR isn't reachable by the caller. |\n\n### Connector keys (3 tools)\n\nOperator actions on `GguiUserApiKey` rows — the user-facing `ggui_user_*` API key strings that Claude Desktop (and other Connectors) present to call the MCP routes on the user's behalf. Bound on the cloud pod via the `issueGguiUserApiKey` AppSync mutation + the `apiKeysByUserId` GSI + raw DDB `UpdateItem` for revoke.\n\n#### `ggui_ops_list_connector_keys`\n\nRead the calling user's `ggui_user_*` connector keys. **Metadata only — NEVER plaintext.**\n\n**Inputs:** none.\n\n**Returns:** `{ keys: ConnectorKeySummary[] }`\n\n```typescript\ninterface ConnectorKeySummary {\n  readonly id: string; // stable id for revoke\n  readonly apiKeyPrefix: string; // first ~8 chars of the secret (human re-identification)\n  readonly name?: string; // user-supplied label\n  readonly appId?: string; // optional FK — when set the key locks to that app\n  readonly status: \"active\" | \"revoked\";\n  readonly createdAt: string;\n  readonly lastUsedAt?: string; // from the last successful auth lookup\n  readonly expiresAt?: string; // past timestamp ⇒ adapter rejects auth\n}\n```\n\nThe hash itself is never returned on any tool.\n\n#### `ggui_ops_issue_connector_key`\n\nMint a fresh `ggui_user_*` connector key.\n\n| Field       | Type                   | Required | Description                                                                                                                                                                                                                             |\n| ----------- | ---------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `name`      | `string` (1–120 chars) | No       | Optional label, e.g. `'MacBook Claude Desktop'`. Surfaces on `ggui_ops_list_connector_keys`.                                                                                                                                            |\n| `appId`     | `string`               | No       | Lock the key to one app. When set, sessions opened with this key scope to the named app and meta-tools (`ggui_ops_open_app`, `ggui_ops_list_apps`) are NOT exposed. Absent ⇒ universal key (scopes to `User.defaultAppId` per request). |\n| `expiresAt` | `string` (ISO 8601)    | No       | Optional expiry. Past timestamps reject auth from the start.                                                                                                                                                                            |\n\n**Returns:**\n\n```typescript\ninterface IssueConnectorKeyOutput {\n  // metadata — same shape as a list row\n  readonly id: string;\n  readonly apiKeyPrefix: string;\n  readonly name?: string;\n  readonly appId?: string;\n  readonly status: \"active\" | \"revoked\";\n  readonly createdAt: string;\n  readonly lastUsedAt?: string;\n  readonly expiresAt?: string;\n  // ONE-TIME REVEAL — never returned again\n  readonly plaintextKey: string;\n}\n```\n\n:::caution[One-time reveal]\n`plaintextKey` is the `ggui_user_<random>` secret. It appears on this response and never again — the adapter persists `sha256(plaintextKey)` hex plus `apiKeyPrefix`, and the plaintext is not stored. The caller MUST surface it to the user immediately.\n:::\n\n#### `ggui_ops_revoke_connector_key`\n\nSoft-revoke a `GguiUserApiKey` row. The adapter sets `status='revoked'`; the auth path rejects revoked keys regardless of hash match. Rows are kept for audit (age-based sweep handles cleanup).\n\n| Field   | Type     | Required | Description                                                                                |\n| ------- | -------- | -------- | ------------------------------------------------------------------------------------------ |\n| `keyId` | `string` | Yes      | Stable id of the row (NOT the secret string). Discover via `ggui_ops_list_connector_keys`. |\n\n**Returns:**\n\n```typescript\ninterface RevokeConnectorKeyOutput {\n  readonly id: string;\n  readonly status: \"active\" | \"revoked\";\n  readonly alreadyRevoked: boolean;\n}\n```\n\n**Errors:**\n\n| Code                          | When                                 |\n| ----------------------------- | ------------------------------------ |\n| `connector_key_access_denied` | The key belongs to another user.     |\n| `connector_key_not_found`     | No such key reachable by the caller. |\n\nIdempotent — re-revoking returns `alreadyRevoked: true`.\n\n### Coupons (1 tool)\n\nOperator action on `GguiCoupon` rows — bearer-secret promo codes that credit user or org wallets. Bound on the cloud pod via the `redeemCoupon` AppSync mutation.\n\n#### `ggui_ops_redeem_coupon`\n\nRedeem a `cpn_*` coupon code, crediting the caller's wallet (default) or a target org's wallet. The adapter runs an atomic three-leg `TransactWrite`:\n\n1. Flip `GguiCoupon.status` from `issued` → `activated`.\n2. Credit the wallet (user or org).\n3. Insert a ledger row.\n\nFailure of any leg rolls all back — no half-credit, no double-spend.\n\n| Field         | Type     | Required | Description                                                                                                           |\n| ------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------- |\n| `couponCode`  | `string` | Yes      | The bearer-secret code in format `cpn_<8 chars>`. One-time redemption.                                                |\n| `targetOrgId` | `string` | No       | When set, credits the named org's wallet instead of the caller's personal wallet. Caller MUST be a member of the org. |\n\n**Returns:**\n\n```typescript\ninterface RedeemCouponOutput {\n  readonly couponCode: string;\n  readonly creditCents: number;\n  readonly redeemedByPrincipalType: \"user\" | \"org\";\n  readonly redeemedByPrincipalId: string;\n  readonly activatedAt: string;\n}\n```\n\n**Errors:**\n\n| Code                      | When                                                                   |\n| ------------------------- | ---------------------------------------------------------------------- |\n| `coupon_not_found`        | The code doesn't exist.                                                |\n| `coupon_already_redeemed` | The code was previously activated (one-time semantics).                |\n| `coupon_expired`          | The code is past its expiry.                                           |\n| `coupon_access_denied`    | `targetOrgId` was provided but the caller is not a member of that org. |\n\n---\n\n## OSS vs hosted\n\nThe apps domain is wired through an optional field on `CreateGguiServerOptions` (ops-blueprint hangs off the `opsBlueprint` dep bundle on `defaultHandlers`; provider-keys + credits are cloud-pod-bound):\n\n```typescript\ninterface CreateGguiServerOptions {\n  readonly opsApps?: {\n    readonly apps: AppsSource;\n    readonly userDefaultApp: UserDefaultAppSource;\n  };\n}\n```\n\n- **Hosted (`mcp.ggui.ai`):** the cloud pod binds the apps domain — AppSync-backed adapters wrap the corresponding mutations. Provider-keys and credits (above) are also bound, and the pod additionally registers its own 24 hosted-only tools, orgs, connector keys and coupons among them (see [Hosted-only ops](#hosted-only-ops)) — the full ops surface is already live on `/control`.\n- **OSS (`ggui serve`):** every field is `undefined` by default. The route still mounts but `tools/list` rejects with `Method not found` — no tools capability is advertised when zero handlers are registered. Operator tools only make sense alongside a data model to operate on; the ops-blueprint family is the one most self-hosters wire (via the `opsBlueprint` dep bundle).\n- **Wiring apps:** a self-hosted deployment with its own `AppsSource` and `UserDefaultAppSource` binds `opsApps` and gets the six `ggui_ops_*_app` tools.\n- **Before v0.25.0:** orgs, connector keys and coupons were open handler families wired through `opsOrgs`, `opsConnectorKeys` and `opsCoupon`. Organisations, wallets and user API keys are features of the hosted service, so those families now ship only there; a deployment that bound them can stay on v0.24.0, or copy the handlers into its own code and register them through the `handlers` option.\n\nThe seam interfaces (`AppsSource`, `UserDefaultAppSource`) are exported from `@ggui-ai/mcp-server-handlers` — implementing them against your own backend is the integration path for downstream forks.\n\n## Console parity\n\nThe [console UI](/hosted/console/) mirrors these tools 1:1 — every tool corresponds to one UI action:\n\n| Tool                                                                      | Console surface                                                                                                                                                                                                                          |\n| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `ggui_ops_list_apps`                                                      | Apps section — main list.                                                                                                                                                                                                                |\n| `ggui_ops_create_app`                                                     | Apps section — \"New app\" button.                                                                                                                                                                                                         |\n| `ggui_ops_update_app`                                                     | Apps section — inline rename + System Prompt editor + rate-limit setting.                                                                                                                                                                |\n| `ggui_ops_set_app_theme`                                                  | Apps section → Theme editor.                                                                                                                                                                                                             |\n| `ggui_ops_delete_app`                                                     | Apps section — row menu → Delete. Both paths converge on the app-delete cascade worker (see the tool’s section above); the console removes the app row synchronously, the tool’s dependent-row sweep is eventually consistent (seconds). |\n| `ggui_ops_set_default_app`                                                | Apps section — \"Set as default\" toggle.                                                                                                                                                                                                  |\n| `ggui_ops_list_orgs`                                                      | Orgs section — main list.                                                                                                                                                                                                                |\n| `ggui_ops_create_org`                                                     | Orgs section — \"New org\" button.                                                                                                                                                                                                         |\n| `ggui_ops_rename_org`                                                     | Orgs section — org header → Rename.                                                                                                                                                                                                      |\n| `ggui_ops_remove_org_member`                                              | Orgs section → Members → row menu → Remove / \"Leave org\".                                                                                                                                                                                |\n| `ggui_ops_get_org_balance`                                                | Orgs section → Wallet balance readout.                                                                                                                                                                                                   |\n| `ggui_ops_invite_to_org`                                                  | Orgs section → Members → Invite.                                                                                                                                                                                                         |\n| `ggui_ops_revoke_invite`                                                  | Orgs section → Members → pending invite row → Revoke.                                                                                                                                                                                    |\n| `ggui_ops_list_connector_keys`                                            | Account → Connector Keys list.                                                                                                                                                                                                           |\n| `ggui_ops_issue_connector_key`                                            | Account → Connector Keys → \"Issue new key\".                                                                                                                                                                                              |\n| `ggui_ops_revoke_connector_key`                                           | Account → Connector Keys → row menu → Revoke.                                                                                                                                                                                            |\n| `ggui_ops_redeem_coupon`                                                  | Billing → Redeem coupon.                                                                                                                                                                                                                 |\n| `ggui_ops_set_provider_key` / `ggui_ops_remove_provider_key` with `appId` | App settings → Provider Keys (per-app override).                                                                                                                                                                                         |\n\nThe MCP surface and the UI surface are siblings over the same seam — they write the same rows — the console's \"New app\" and `ggui_ops_create_app` both go through `AppsSource.create`, for example. There's no privileged path on either side.\n\n---\n\n## Example: curl\n\nThis walkthrough targets a self-hosted server with the ops seams wired (started with `--dev-allow-all` for the `Bearer dev` shortcut). On hosted GGUI, the same calls go to `https://mcp.ggui.ai/control` with an OAuth bearer.\n\nNote the `confirm` argument on the state-changing steps — without it, each returns a preview instead of acting.\n\n```bash\n# 1. Initialize\ncurl -X POST http://127.0.0.1:6781/control \\\n  -H \"Authorization: Bearer dev\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-11-25\",\"clientInfo\":{\"name\":\"curl\",\"version\":\"1.0\"},\"capabilities\":{}}}'\n\n# 2. Enumerate the caller's apps\ncurl -X POST http://127.0.0.1:6781/control \\\n  -H \"Authorization: Bearer dev\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"ggui_ops_list_apps\",\"arguments\":{}}}'\n\n# 3. Create a fresh app\ncurl -X POST http://127.0.0.1:6781/control \\\n  -H \"Authorization: Bearer dev\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"ggui_ops_create_app\",\"arguments\":{\"displayName\":\"Inbox Triage\",\"confirm\":true}}}'\n\n# 4. Promote the new app to default (use the appId from step 3's response)\ncurl -X POST http://127.0.0.1:6781/control \\\n  -H \"Authorization: Bearer dev\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"ggui_ops_set_default_app\",\"arguments\":{\"appId\":\"<appId>\",\"confirm\":true}}}'\n```\n\nOn hosted GGUI, a fifth call — `ggui_ops_issue_connector_key` with `{\"name\":\"MacBook Claude Desktop\",\"appId\":\"<appId>\",\"confirm\":true}` — mints a connector key locked to the new app; its response carries `plaintextKey`, to surface to the user immediately (see [Connector keys](#connector-keys-3-tools)).\n\nThe same calls can be made through the [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) client by pointing the transport at `/control` instead of `/mcp` — the tool registration shapes are standard.\n\n---\n\n## See Also\n\n- [Console](/hosted/console/) — the human-facing surface for the same actions.\n- [Audience Routes](/architecture/audience-routes/) — the `agent` / `runtime` / `protocol` / `ops` tag model and how it projects onto the two surfaces.\n- [MCP Protocol Reference](/api/mcp-protocol/) — the sibling agent-loop surface on `/mcp`."},{"path":"/api/mcp-apps.md","title":"MCP Apps support","description":"How GGUI implements the io.modelcontextprotocol/ui capability so hosts like Claude Desktop, claude.ai, Goose, and VS Code Copilot render generative UIs inline.","body":"---\ntitle: MCP Apps support\ndescription: How GGUI implements the io.modelcontextprotocol/ui capability so hosts like Claude Desktop, claude.ai, Goose, and VS Code Copilot render generative UIs inline.\n---\n\n[MCP Apps](https://modelcontextprotocol.io/extensions/apps/overview) is the protocol extension that lets MCP servers ship interactive UI alongside structured data, and lets MCP hosts render those UIs inline in the chat surface. The OSS [`@ggui-ai/mcp-server`](/quickstart/self-hosted/) implements the wire format on both sides — as does the hosted GGUI server (`mcp.ggui.ai`) — so generative UIs render directly in chat instead of forcing a \"click this link\" detour to a browser tab.\n\nThis page documents the protocol pieces GGUI implements. For end-user setup, see [Connect Claude Desktop](/clients/claude-desktop/). For the underlying transport, see [WebSocket protocol](/api/websocket-protocol/).\n\n## What MCP Apps adds\n\nWithout MCP Apps, an MCP tool that produces UI has to choose between:\n\n1. Returning structured data and hoping the host formats it (no interactivity), or\n2. Returning a URL the user clicks out to (interactive, but chat and UI live in separate windows).\n\nMCP Apps adds a third option: declare a UI resource alongside the tool result, the host sandboxes it in an iframe inside the chat, and a WebSocket channel carries data both ways — host to UI for live updates, UI to server for actions.\n\n## What GGUI ships\n\nWhen the server boots with `mcpApps` enabled, three things happen:\n\n1. **`io.modelcontextprotocol/ui` is advertised** in the server's `initialize` capabilities (under `experimental`). MCP-Apps-aware hosts read this and switch on inline rendering.\n2. **`ui://ggui/render` is served** as a resource via `resources/read` — a minimal HTML shell that loads [`@ggui-ai/iframe-runtime`](https://www.npmjs.com/package/@ggui-ai/iframe-runtime) and opens the WebSocket channel.\n3. **Every `ggui_render` tool result carries `_meta.ui.resourceUri`** — the `ui://` locator naming the view. Whether the result ALSO carries the `_meta[\"ai.ggui/render\"]` bootstrap slice depends on the deployment posture; see [Tool result shape](#tool-result-shape) below. Hosted GGUI withholds it, self-hosted `ggui serve` inlines it.\n\n### The bootstrap slice\n\nWherever it is delivered — on the tool result, or inlined by a per-render shell — the `_meta[\"ai.ggui/render\"]` slice is the same shape: `sessionId`, `appId`, `runtimeUrl`, `wsUrl`, a short-TTL `wsToken`, and `expiresAt` (ISO 8601 string) as top-level fields, alongside capability + render-state fields, plus theme fields (`themeId`, `themeMode`, `theme` — the app's theme, its `--ggui-*` projection for both modes, which the iframe merges into the scoped token block the components read), a `pollingUrl` fallback for WS-blocked environments, `lastSequence` to seed replay cursors, and `epoch`. The iframe consumes the slice and opens the WebSocket with the `wsToken`, which it presents again on a reconnect.\n\n`epoch` is the history epoch this mount belongs to, and it is load-bearing rather than decorative. `ggui_render` mints epoch 0; each `ggui_update` result carries its new epoch; a pinned `#N` read carries N; absent reads as 0. The iframe records it at boot as its OWN epoch, and when a later [`props_update`](/api/websocket-protocol/#props_update) frame arrives carrying a HIGHER epoch, this mount has been superseded and freezes instead of applying the new props. A custom runtime that ignores `epoch` will happily repaint a stale history card with state that belongs to a newer one — the exact bug the freeze latch exists to prevent.\n\n## Capability declaration\n\nOn `initialize`, GGUI returns:\n\n```json\n{\n  \"capabilities\": {\n    \"tools\": { \"listChanged\": true },\n    \"resources\": { \"subscribe\": false, \"listChanged\": false },\n    \"experimental\": {\n      \"io.modelcontextprotocol/ui\": {}\n    }\n  }\n}\n```\n\nHosts that recognize `io.modelcontextprotocol/ui` flip into inline-render mode. Hosts that don't simply ignore the capability and skip inline rendering — the render is still delivered as a resource, addressed by `_meta.ui.resourceUri` (see [Render locator grammar](#render-locator-grammar)), but without MCP-Apps support there is nothing to mount it. There is no agent-returned URL to open instead.\n\n## Tool result shape\n\nEvery UI-producing tool (today: `ggui_render`) stamps a locator on the result so the host knows where to load the UI from. There are **two postures**, and a host builder must handle the one their server runs.\n\n### Read-plane-only — what `https://mcp.ggui.ai` returns\n\nHosted GGUI runs read-plane-only. The result publishes the durable identity and nothing else: no `ai.ggui/render` slice, no `wsToken`. No bootstrap token is minted for a slice nobody receives.\n\n```json\n{\n  \"content\": [{ \"type\": \"text\", \"text\": \"Created render render_abc123\" }],\n  \"structuredContent\": {\n    \"sessionId\": \"render_abc123\",\n    \"resourceUri\": \"ui://ggui/render/render_abc123/bp_9f2c\"\n    // … plus action / contractHash / blueprintId / variantKey / cache\n  },\n  \"_meta\": {\n    \"ui\": { \"resourceUri\": \"ui://ggui/render/render_abc123/bp_9f2c\" },\n    \"ui/resourceUri\": \"ui://ggui/render/render_abc123/bp_9f2c\"\n  }\n}\n```\n\nThe host MUST resolve the view by an authenticated `resources/read` on that locator. What comes back is a **per-render self-contained shell** — an HTML document that inlines the bootstrap slice synchronously on `globalThis.__GGUI_META__`, so the runtime reads it directly and never waits for a host relay. `ui/resourceUri` is the flat legacy alias of the same value, carried for hosts that read that slot.\n\nReading the locator is the shortest path, not the only one. A host that mounts the **declaration-level** static shell instead, the `_meta.ui.resourceUri` announced on `tools/list`, still paints, provided it forwards this result via `ui/notifications/tool-result` and answers `resources/read` from the view. The shell finds only the locator on the result, asks the host to read it, and mounts from the envelope that comes back: the [read-plane door](/protocol/bootstrap-handshake/#the-read-plane-door). That costs one extra host-proxied round trip. A host that cannot proxy the read ends on a failure card after a bounded wait: it reports `MISSING_META_GGUI_BOOTSTRAP`, and its message names the `READ_DOOR_FAILED:` cause (see [the read-plane door](/protocol/bootstrap-handshake/#the-read-plane-door)).\n\n### Inlined bootstrap — the self-hosted `ggui serve` default\n\nWithout the withhold posture, the result carries the bootstrap slice inline and a host may mount directly, with no further round-trip:\n\n```json\n{\n  \"content\": [{ \"type\": \"text\", \"text\": \"Created render render_abc123\" }],\n  \"_meta\": {\n    \"ui\": {\n      \"resourceUri\": \"ui://ggui/render/render_abc123\"\n    },\n    \"ai.ggui/render\": {\n      \"sessionId\": \"render_abc123\",\n      \"appId\": \"app_abc\",\n      \"runtimeUrl\": \"https://your-server.example.com/_ggui/iframe-runtime.js\",\n      \"wsUrl\": \"wss://your-server.example.com/ws\",\n      \"wsToken\": \"btkn_…\",\n      \"expiresAt\": \"2099-01-01T00:00:00.000Z\",\n      \"epoch\": 0\n    }\n  }\n}\n```\n\nAny deployment can adopt the read-plane-only posture by setting `withholdResultMeta` on the server factory. The identity pointer stays in both postures — the identity IS the pointer, and spec-canonical hosts mount from it.\n\nThe tool **declaration** (returned by `tools/list`) is what carries `_meta.ui.visibility: [\"model\"]` — the MCP Apps signal that this tool ships a renderable UI surface. Per-call **results** stamp the locator, as above.\n\n### Render locator grammar\n\n```\nui://ggui/render/<sessionId>[/<blueprintKey>][#<epoch>]\n```\n\n| Segment        | Presence                                              | Meaning                                                                                                                                                                                                                                        |\n| -------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `sessionId`    | Always.                                               | The render to resolve.                                                                                                                                                                                                                         |\n| `blueprintKey` | On every render since the resume contract landed.     | Lets a read fall back to a registry-only mount (the original card with default props) when the render row is gone, instead of returning a typed failure. The segment is named for its domain; the record field it comes from is `contractKey`. |\n| `#<epoch>`     | On `ggui_update` results; absent means the live head. | Pins the immutable epoch-N record. Pinned reads serve identical content forever (conformance-pinned); the bare URI tracks the session's current state.                                                                                         |\n\nBoth the single-segment and two-segment shapes resolve. The single-segment form is kept registered so pre-resume-contract cards still in chat history rehydrate — parse for the optional second segment rather than assuming one is absent.\n\n## The shell at `ui://ggui/render`\n\nReading the resource returns a small HTML document — paper-themed, full-bleed, no chrome — whose only job is:\n\n1. Obtain the `ai.ggui/render` slice — from the host via `postMessage`, or, for a per-render self-contained shell, from the `globalThis.__GGUI_META__` global it inlines synchronously before the bundle loads.\n2. Dynamically load `runtimeUrl` (the iframe-runtime bundle).\n3. Hand the slice to the runtime, which opens the WebSocket and starts rendering.\n\nThe shell is intentionally minimal. The actual rendering work — component resolution, contract validation, action dispatch, gadget loading — lives in [`@ggui-ai/iframe-runtime`](https://www.npmjs.com/package/@ggui-ai/iframe-runtime), which the shell loads on demand. This keeps the shell payload tiny and lets the runtime version-bump independently of host caches.\n\n### Three shell registrations\n\nReading traffic against a real server, you will see more than one shell URI. There are three registrations, and they all serve the same current shell:\n\n| URI                           | Who asks for it                                                                         |\n| ----------------------------- | --------------------------------------------------------------------------------------- |\n| `ui://ggui/render`            | Grandfathered sessions and hosts that read the bare URI directly.                       |\n| `ui://ggui/render/rt-<sha12>` | The content-addressed twin. **Tool declarations advertise THIS URI**, not the bare one. |\n| `ui://ggui/render/rt-<any>`   | Stale-hash grandfather template — serves the current shell under any prior hash.        |\n\nThe hash covers the full served representation: the shell bytes AND the `_meta.ui.csp` declaration the response carries. Hosts cache the prefetched shell keyed on the resource URI, so the URI must change exactly when the content or the CSP policy does — and never otherwise. The meta has to be in the hash input because hosts cache the declaration alongside the bytes: a policy-only change would otherwise ship under an old URI and never reach cached frames.\n\nThe grandfather template exists because hosts snapshot tool declarations. After a shell-changing deploy they keep asking for the previous deploy's `rt-` URI; without the template that read falls through to the per-session template (a bare `rt-abc…` segment parses as a `sessionId`), resolves no render, and the host shows a mount failure.\n\n### The `_meta.ui.csp` declaration\n\nShell responses carry a `_meta.ui.csp` block with `connectDomains`. Hosts derive the frame's `connect-src` from it — claude.ai applies a restrictive default (`connect-src 'none'`) unless the resource declares otherwise, which blocks the cross-origin runtime `<script>` load and every rung of the live-channel failover ladder. Declare the public base URL, the runtime URL, and the live-channel origins; declaring only the runtime CDN origin is the known way to get a frame that boots and then cannot connect.\n\nNote the limit, honestly: not every host honors the declaration. Claude Desktop's iframe sandbox does not honor it for `wss://`, which is why the transport fail-fasts out of its reconnect ladder and pivots to polling — see [Reconnection](/api/websocket-protocol/#reconnection).\n\n## Bootstrap token exchange\n\nThe `wsToken` is short-lived (default 180s) and reusable within its TTL, so a transient WebSocket drop reconnects without a fresh handshake. A reconnect presents the wsToken again; there is no separate reconnect credential. After the wsToken expires, the view swaps the envelope via `ggui_runtime_refresh_ws_token`, which re-mints it, at any age, for a caller admitted to the session, or re-bootstraps. Consequences:\n\n- Hosts can cache the resource document, but the bootstrap is per-call — every render mints a fresh token.\n- An iframe that loses connection reconnects with its live wsToken without re-fetching the resource or re-running OAuth.\n- A leaked bootstrap token is useless after the TTL expires. Its holder can renew it through the snapshot read (`/api/sessions/<id>/state`), but a chain of renewals ends one refresh window (default twice the TTL) after its root was minted; past that, a new credential needs an authorized door, such as `ggui_runtime_refresh_ws_token` for a caller admitted to the session.\n\nThe bootstrap is HMAC-signed with a server-side `wsTokenSecret`. Multi-pod deployments MUST share a deterministic secret (typically from a secrets manager) so any pod accepts any other pod's tokens, and should share `wsTokenTtlSec` and `wsTokenRefreshWindowSec` too, so a renewal chain ends at the same second on every pod. The handshake details are documented in [Bootstrap handshake](/protocol/bootstrap-handshake/).\n\n## Self-hosted: enabling MCP Apps in your own server\n\n```typescript\nimport { createGguiServer } from \"@ggui-ai/mcp-server\";\n\nconst server = createGguiServer({\n  // ...\n  renderChannel: true, // required — MCP Apps needs the WS channel\n  mcpApps: {\n    wsUrl: \"wss://your-server.example.com/ws\",\n  },\n  runtime: true, // serve the iframe-runtime bundle\n  wsTokenSecret: process.env.WS_TOKEN_SECRET, // required for multi-pod\n});\n```\n\nFor local dev, `wsUrl: \"ws://127.0.0.1:6781/ws\"` is the conventional loopback URL. Hosted GGUI uses `wss://mcp.ggui.ai/ws`.\n\nWhat each option does:\n\n- **`renderChannel: true`** — mounts the live-channel WebSocket at `/ws`. MCP Apps requires it; the iframe has nowhere to connect without one.\n- **`mcpApps.wsUrl`** — the publicly-reachable WebSocket URL written onto every bootstrap. Don't ship `ws://localhost:…` to internet-accessible servers — clients must be able to reach it.\n- **`runtime: true`** (default when `mcpApps` is on) — mounts the iframe-runtime bundle at `/_ggui/iframe-runtime.js`. Pass `runtime: { url: \"https://your-cdn/…\" }` to point at an externally-hosted bundle.\n- **`wsTokenSecret`** — HMAC secret. If omitted, the server mints a random secret at boot — fine for single-process dev, wrong for multi-pod (pods would reject each other's tokens).\n- **`wsTokenTtlSec`** (default 180) and **`wsTokenRefreshWindowSec`** (default twice the TTL) — the lifetime of the wsTokens the server mints (a snapshot-read renewal gets less when its chain's window ends sooner), and how long a chain of snapshot-read renewals may run from its root. The window must be at least the TTL; the factory throws otherwise.\n\n`mcpApps` requires `renderChannel: true`; the factory throws at construction if you enable one without the other.\n\n## Compatibility matrix\n\nHost capabilities below describe what each MCP host supports, whether connected to your self-hosted server or the hosted GGUI connector:\n\n| Host                | OAuth | MCP Apps | Notes                                                                                                |\n| ------------------- | ----- | -------- | ---------------------------------------------------------------------------------------------------- |\n| Claude Desktop      | Yes   | Yes      | Inline rendering, full UX. ([install](/clients/claude-desktop/))                                     |\n| claude.ai (web)     | Yes   | Yes      | Same as Desktop.                                                                                     |\n| Goose               | Yes   | Yes      | Inline rendering in TUI mode varies by terminal.                                                     |\n| VS Code Copilot     | Yes   | Yes      | UI renders in a side panel.                                                                          |\n| Cursor              | Yes   | Partial  | OAuth works; MCP Apps support depends on version.                                                    |\n| Claude Code         | Yes   | No       | Tools work; results arrive as text, no card. Sign in with `/mcp`. ([connect](/clients/claude-code/)) |\n| Generic MCP runtime | No    | No       | Static `Authorization: Bearer …`; no inline render — resolve the `resourceUri` resource yourself.    |\n\nIf your host doesn't yet implement MCP Apps, the underlying render still works — you just lose inline rendering. Each render is delivered as an MCP-Apps resource addressed by `_meta.ui.resourceUri` (see [Render locator grammar](#render-locator-grammar)); resolve it with `resources/read`. There is no render-viewer URL the agent receives.\n\n## Reference\n\n- MCP Apps protocol: <https://modelcontextprotocol.io/extensions/apps/overview>\n- GGUI server factory: [`@ggui-ai/mcp-server`](https://www.npmjs.com/package/@ggui-ai/mcp-server)\n- Iframe runtime: [`@ggui-ai/iframe-runtime`](https://www.npmjs.com/package/@ggui-ai/iframe-runtime)\n- Wire envelopes: [Envelopes](/protocol/envelopes/)\n- Glossary terms: [gadget, tool, blueprint](/glossary/)"},{"path":"/api/mcp-docs.md","title":"Docs MCP service","description":"Anonymous MCP service at mcp.ggui.ai/docs — three tools (docs_search, docs_read, docs_list) for any LLM to query GGUI documentation programmatically.","body":"---\ntitle: Docs MCP service\ndescription: Anonymous MCP service at mcp.ggui.ai/docs — three tools (docs_search, docs_read, docs_list) for any LLM to query GGUI documentation programmatically.\n---\n\n_Hosted surface — part of [hosted GGUI](/hosted/console/)._\n\nThe Docs MCP service is a public, anonymous-auth Model Context Protocol surface that lets any LLM agent query the GGUI documentation corpus from inside its tool loop. Three read-only tools, one in-memory index, no token required.\n\nOne clarification on what's open vs. hosted: the `McpService` mount primitive this service is built on **is OSS** (it ships in `@ggui-ai/mcp-server` — you can mount your own anonymous docs-style service today), and the hosted docs corpus service itself is live at `mcp.ggui.ai`.\n\nIf you're building a coding assistant, an IDE plugin, or any agent that needs to ground its answers in GGUI's docs, point it at `https://mcp.ggui.ai/docs` and the model gets `docs_search` / `docs_read` / `docs_list` for free.\n\n## What it is\n\nA first-party MCP service mounted at `https://mcp.ggui.ai/docs` exposing three tools that wrap an in-memory index of the docs corpus:\n\n- `docs_search` — keyword search, ranked by occurrence count with a 3× title weighting.\n- `docs_read` — fetch the raw markdown body of one doc by path.\n- `docs_list` — enumerate every doc in the corpus, optionally filtered by path prefix.\n\nThe service is built on the same `McpService` primitive that hosts the main agent API at `mcp.ggui.ai`. See [MCP services architecture](/architecture/mcp-services/) for the anonymous-mode mechanics and how multiple services are composed into one `createGguiServer` boot.\n\n## Why anonymous\n\nThe GGUI docs corpus is public — every page on this site is reachable without a login, and every byte the service returns is something a browser could fetch by following a URL. There is no per-user state, no rate-shaped quota tied to identity, no privileged content behind the corpus.\n\nForcing OAuth on a read-only public surface adds setup friction (Dynamic Client Registration ceremony, token refresh, secret storage) for zero security gain. Anonymous mode is the intentional default for surfaces that meet **all** of:\n\n- The data is already public.\n- The service has no side effects (no writes, no mutating tool calls, no external API spend).\n- Per-user state would be a lie (the same query produces the same answer for everyone).\n\nThe agent surface at `mcp.ggui.ai` does **not** meet these criteria — sessions, BYOK credentials, and per-app rate limits all require an identified caller. The docs surface does, so it skips auth.\n\n## Endpoint\n\n```\nPOST https://mcp.ggui.ai/docs\n```\n\nServed as a **separate** MCP server, not folded into `/mcp`. The two surfaces have different auth modes (anonymous vs. OAuth) and different tool catalogs; keeping them on distinct paths means a docs-only client never has to discover or skip past agent-loop tools, and the agent loop never has to surface read-only docs tools alongside its session lifecycle.\n\nNo `Authorization` header is required. `Content-Type: application/json` and the JSON-RPC 2.0 envelope are still required — this is MCP over HTTP, identical wire shape to `/mcp`, only the auth handshake is skipped.\n\n`/docs` is a standalone service with its own path — the design-time `ggui_protocol_*` tools live on the [`/control` plane](/api/control-mcp/) instead, which is likewise anonymous for that half.\n\n## The three tools\n\n### `docs_search`\n\nKeyword search over the corpus. The query is tokenized on whitespace and lowercased; matches are case-insensitive. Each hit's score is the sum of token-occurrence counts in the doc — title occurrences are weighted **3×**, body occurrences **1×**.\n\nThe server clamps results to a maximum of **50 hits** regardless of the requested `limit`. Default `limit` is 10.\n\n| Field   | Type     | Required | Description                                             |\n| ------- | -------- | -------- | ------------------------------------------------------- |\n| `q`     | `string` | Yes      | Search terms — whitespace-separated; case-insensitive.  |\n| `limit` | `number` | No       | Cap on returned hits. Default 10. Server-clamped to 50. |\n\n**Returns:** `{ hits: SearchHit[] }`\n\n```ts\ninterface SearchHit {\n  path: string; // corpus-relative path, e.g. \"principles/strict-typing.md\"\n  title: string; // first H1, or filename if none\n  summary: string; // first ~200 chars of prose after the title\n  score: number; // keyword score, higher is better\n}\n```\n\nHits are sorted by `score` descending, with a stable secondary sort on `path` ascending so identical-score hits land in deterministic order. Follow up with `docs_read` to fetch the full body of any hit.\n\n### `docs_read`\n\nFetch the full markdown body of one doc by its corpus-relative path. Pair with `docs_search` (search → read top hit) or `docs_list` (browse → read).\n\n| Field  | Type     | Required | Description                                                                                      |\n| ------ | -------- | -------- | ------------------------------------------------------------------------------------------------ |\n| `path` | `string` | Yes      | Path relative to the corpus root, forward slashes. Leading `./` or `/` is trimmed before lookup. |\n\n**Returns:** `{ found, path, title, summary, body, bytes }`\n\n```ts\ninterface DocsReadOutput {\n  found: boolean; // false when the path isn't in the corpus\n  path: string;\n  title: string;\n  summary: string;\n  body: string; // full raw markdown, including any frontmatter\n  bytes: number; // utf-8 byte length of body\n}\n```\n\nWhen the path doesn't match any doc, the tool returns `found: false` with empty `body` / `title` / `summary` rather than throwing — agents see a clean \"no such doc\" result and can fall back to `docs_search` or ask the user to refine.\n\n### `docs_list`\n\nEnumerate every doc in the corpus, optionally filtered by a path prefix. Returns metadata only (path / title / summary); bodies are not included.\n\n| Field    | Type     | Required | Description                                                                                                  |\n| -------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------ |\n| `prefix` | `string` | No       | Case-sensitive path-prefix filter. When set, only entries whose `path` starts with this prefix are returned. |\n\n**Returns:** `{ entries: DocMeta[], total }`\n\n```ts\ninterface DocMeta {\n  path: string;\n  title: string;\n  summary: string;\n}\n```\n\nEntries are sorted by `path` ascending. `total` is the count after the prefix filter — use it to detect a typo'd prefix (e.g. `total: 0` when you expected dozens) without scanning `entries`.\n\nThe full unfiltered list is small — one entry per page, 74 today — comfortably under any sane MCP response budget. Use `prefix` to scope to one section (`principles/`, `protocol/`, `architecture/`, etc.) when you want a focused slice.\n\n## Corpus loading\n\nThe corpus is the docs site's own build artifact, [`/docs-corpus.json`](/docs-corpus.json) — every page of docs.ggui.ai as one JSON document, each `body` exactly the bytes that page's `.md` companion serves (see [LLM agents → What's available](/agents/#whats-available)). The hosted service fetches **its environment's** artifact at boot — `dev.docs.sandbox.ggui.ai` for dev, `staging.docs.sandbox.ggui.ai` for staging, `docs.ggui.ai` for production (`GGUI_DOCS_CORPUS_URL`) — and refreshes it every five minutes with a conditional GET on the artifact's ETag, so a docs deploy reaches the service within that window with no pod deploy involved. `title` and `summary` come from the page's frontmatter (`summary` is the `description` when the page declares one). A self-hosted run can point `DOCS_CORPUS_PATH` at a directory of `.md` files instead; the directory loader exists for that path only.\n\n```ts\ninterface DocsCorpus {\n  list(): readonly DocMeta[];\n  read(docPath: string): DocEntry | null;\n  search(query: string, limit?: number): readonly SearchHit[];\n}\n```\n\nThe whole corpus is held in process memory — 74 pages today, about 1 MB — and every request is served from RAM: no filesystem hit per call, no vector DB, no Algolia. On any refresh failure the last good corpus keeps serving and the service logs one line naming the staleness (`docs_corpus_refresh_failed { lastGoodBuildSha, ageSeconds }`); a boot that can reach nothing serves an empty corpus and says so (`docs_corpus_unavailable`) rather than failing to start. `docs_read` returns exactly the bytes the page's `.md` companion serves, frontmatter envelope included — nothing is re-rendered service-side — and `docs_search`'s index is built from the fetched records.\n\nThe `DocsCorpus` interface is the upgrade seam. A future v2 can drop in an embedding-backed implementation — precomputed vectors, semantic ranking — without changing the tool surface or the wire shape. Agents written against today's keyword search keep working; quality improves under their feet.\n\n## Connecting from agents\n\n### Claude Agent SDK\n\nAdd the service to the `mcpServers` block of your agent config:\n\n```ts\nimport { query } from \"@anthropic-ai/claude-agent-sdk\";\n\nconst result = await query({\n  prompt: \"How do I write a blueprint for a contact form?\",\n  options: {\n    mcpServers: {\n      docs: {\n        type: \"http\",\n        url: \"https://mcp.ggui.ai/docs\",\n      },\n    },\n    // No `authToken` needed — service is anonymous.\n  },\n});\n```\n\nTools surface to the model as `mcp__docs__docs_search`, `mcp__docs__docs_read`, and `mcp__docs__docs_list`. The SDK auto-discovers them on the first turn and injects them into the model's tool list.\n\n### Raw `@modelcontextprotocol/sdk`\n\nFor non-Claude agents (Gemini, GPT, local models) talking MCP directly:\n\n```ts\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\n\nconst client = new Client({ name: \"my-agent\", version: \"1.0.0\" });\nconst transport = new StreamableHTTPClientTransport(new URL(\"https://mcp.ggui.ai/docs\"));\nawait client.connect(transport);\n\nconst tools = await client.listTools();\n// [\"docs_search\", \"docs_read\", \"docs_list\"]\n\nconst hits = await client.callTool({\n  name: \"docs_search\",\n  arguments: { q: \"audience routes principle\", limit: 5 },\n});\n```\n\n### cURL\n\nFor one-off probes or wiring into a shell pipeline:\n\n```bash\n# Search\ncurl -X POST https://mcp.ggui.ai/docs \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"docs_search\",\"arguments\":{\"q\":\"blueprint first\",\"limit\":5}}}'\n\n# Read one doc\ncurl -X POST https://mcp.ggui.ai/docs \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"docs_read\",\"arguments\":{\"path\":\"principles/blueprint-first-architecture.md\"}}}'\n\n# List one section\ncurl -X POST https://mcp.ggui.ai/docs \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"docs_list\",\"arguments\":{\"prefix\":\"principles/\"}}}'\n```\n\n:::tip[Coding-assistant docs lookup]\nIf you're building an LLM coding-assistant that helps developers write GGUI agents — IDE plugin, chat bot, code-review tool — the cleanest pattern is: wire `docs_search` + `docs_read` into your agent's `mcpServers`, then add a system-prompt instruction along the lines of \"before answering questions about GGUI APIs, call `docs_search` to ground your answer in the current docs.\" The model learns the two-step pattern (search → read top hit) on its own, and your assistant's answers track the live docs corpus instead of drifting with the model's training cutoff.\n:::\n\n## What it pairs with\n\nThe Docs MCP service is the **runtime** half of GGUI's LLM-agent surface. The static half lives at [Agents track](/agents/) — narrative guides, recipe cookbooks, and step-by-step walkthroughs of agent patterns. Use the static docs to learn the protocol; use the Docs MCP service inside your agent to look up the specifics on demand.\n\nIt also pairs naturally with the main agent API at [`/mcp`](/api/mcp-protocol/). An agent that's already authenticated for session work can add the docs service as a second `mcpServers` entry — the two are independent endpoints with independent auth.\n\n## Limits\n\n- **Search hit cap: 50.** Any `limit` above 50 is silently clamped server-side. For corpus-wide enumeration use `docs_list`, not a huge `docs_search` limit.\n- **No streaming.** All three tools are request/response. There is no `tools/streaming` variant — bodies are small enough that streaming would add latency without benefit.\n- **Refresh window: five minutes.** The corpus is re-fetched by ETag every five minutes; a docs deploy is visible to `docs_read` and `docs_search` within that window, with no pod deploy. Between refreshes the corpus is immutable.\n- **Pages only.** The corpus is the docs site's page set — the same pages the sitemap lists. Images, separately hosted code samples and any non-page content are not indexed; the agent gets exactly the markdown the site serves as each page's `.md` companion, never screenshots or rendered HTML.\n- **Anonymous.** Anyone can call these tools without identifying themselves. Do not assume there is a per-user audit trail on this surface — there isn't. (Rate limiting at the edge still applies; behavior under high load is best-effort.)\n\n## See also\n\n- [MCP services architecture](/architecture/mcp-services/) — how anonymous services compose with auth'd services on one host\n- [MCP Protocol Reference](/api/mcp-protocol/) — the main agent API at `mcp.ggui.ai`\n- [LLM agents](/agents/) — narrative track for building agents against GGUI"},{"path":"/api/mcp-protocol.md","title":"MCP Protocol Reference","description":"Wire-level reference for the GGUI MCP HTTP API — tools, inputs, return shapes, and a worked curl flow against self-hosted `ggui serve`.","body":"---\ntitle: MCP Protocol Reference\ndescription: Wire-level reference for the GGUI MCP HTTP API — tools, inputs, return shapes, and a worked curl flow against self-hosted `ggui serve`.\n---\n\nThe GGUI MCP API is [Model Context Protocol](https://modelcontextprotocol.io/) over HTTP with JSON-RPC 2.0. This page is the wire reference: tool names, input shapes, return shapes, error codes, and a complete curl walkthrough.\n\n:::note[One wire — self-hosted and hosted]\nThe agent surface documented below — handshake → render → consume + the rest of the render loop — is served by **self-hosted `ggui serve`**; snippets target `http://127.0.0.1:6781/mcp`. **Hosted GGUI** (`mcp.ggui.ai`) speaks the same wire byte-for-byte — only the URL and the `Authorization` header change. The hosted deployment also layers on extra platform-management tools (app + BYOK + billing surfaces); those aren't part of the agent loop.\n:::\n\nThree-noun vocab in play on this page: **blueprint** (a cached recipe routed by `BlueprintSearch` over the draft's contract + variance axes), **tool** (an agent-side MCP method the LLM invokes), **gadget** (a renderer-side capability the generated component imports). If those terms are new, skim the [glossary](/glossary/) first.\n\n## Endpoint\n\n**Self-hosted (`ggui serve`):**\n\n```\nPOST http://127.0.0.1:6781/mcp\n```\n\n:::note[Hosted GGUI]\nThe hosted deployment exposes the same surface in two forms: universal `POST https://mcp.ggui.ai/` (the cloud overrides the `/mcp` path to the bare root) and per-app `POST https://mcp.ggui.ai/apps/<appId>` (no `/mcp` suffix — that suffix is local `ggui serve` only).\n:::\n\n:::note[This page covers `/mcp` only]\nThis reference documents the `/mcp` route (audience: agent + runtime). Six `ggui_runtime_*` tools (`submit_action`, `pull`, `sync_context`, `refresh_ws_token`, `telemetry`, `declare_tool_catalog`) also route on `/mcp` but are view/backend-facing. They are marked `_meta.ui.visibility: [\"app\"]`, and a conforming host must keep them from the model (SPEC §4.7, \"Host obligation\"); the server still lists them in `tools/list` and does not enforce the marker. Agents never call them. Other audiences and standalone services have their own routes and their own references:\n\n- `/control` — the control plane: design-time protocol / conformance discovery (anonymous) plus operator agent tools (authenticated). See [Conformance](/protocol/conformance/) and [Control MCP plane](/api/control-mcp/).\n- `/docs` — anonymous docs search. See [Docs MCP route](/api/mcp-docs/).\n- `/playground/*` — hosted demo services. See [Playground · Todos](/hosted/playgrounds/todos/) and [Playground · MDH](/hosted/playgrounds/mdh/) at `/playground/the-million-dollar-homepage`.\n  :::\n\n## Authentication\n\nLocal dev: start the server with `ggui serve --dev-allow-all` and any bearer (conventionally `dev`) authenticates as the `builder` identity:\n\n```\nAuthorization: Bearer dev\nContent-Type: application/json\n```\n\nDefault `ggui serve` is **strict** — only pairing-minted bearers authenticate `/mcp`. Pair a key via the pair code the server prints at boot, or mint one locally with `ggui keys create --keys-file <path>` (the same file `ggui serve --keys-file` reads). The bare `createGguiServer` factory defaults to dev-allow-all until you pass `auth` — swap in a real `AuthAdapter` before exposing the port beyond `127.0.0.1`.\n\nHosted GGUI uses OAuth 2.0 with Dynamic Client Registration — Claude Desktop and other MCP-Apps hosts run the ceremony for you; raw-HTTP callers present the issued bearer token on every call. See [OAuth on mcp.ggui.ai](/self-hosted/oauth/) for the ceremony.\n\n## Render Lifecycle\n\n```\n1. initialize           → MCP handshake (one-shot per connection)\n2. ggui_list_gadgets    → Optional: fetch the gadget catalog\n3. ggui_handshake       → Negotiate the wire (handshakeId + suggestion)\n4. ggui_render          → Materialize the UI (outcome: rendered mints sessionId)\n5. ggui_consume         → Long-poll for user events (keyed by sessionId)\n6. ggui_amend           → Repaint the mounted card in place (the gesture loop)\n7. ggui_update          → Render updated state as a NEW card (advances history)\n8. ggui_emit            → Optional: push frames on a streamSpec channel\n9. ggui_get_render_source → Optional: read back the source you shipped\n```\n\nThere is no explicit close ceremony, because there is nothing to close. A render is a durable record: the agent simply stops addressing it, and the card stays reopenable from chat history via `resources/read` on its `ui://` locator. On hosted GGUI the default retention is effectively indefinite, and there is no per-render time limit.\n\n`ggui_amend`, `ggui_update`, and `ggui_consume` are keyed by `sessionId` (globally unique); the server tenancy-checks via the bearer token.\n\n## Rendering Pipeline\n\nThe rendering decision is made during `ggui_handshake`. The negotiator runs `BlueprintSearch` plus contract validation in parallel and returns a routed `suggestion` whose `origin` tag tells the agent which branch fired:\n\n1. **`origin: 'cache'`** — a hit in the semantic cache for generative UI: an exact or semantic match against a registered blueprint. Free, deterministic reuse on the paired render.\n2. **`origin: 'agent'`** — no cache hit, but the agent's draft passed validation. Gen runs against the agent's contract verbatim.\n3. **`origin: 'synth'`** — no cache hit AND validation surfaced amendments. The server amends the draft and gen runs against the amended contract.\n\nThe handshake response carries `handshakeId`, `action`, and `suggestion` (always with a provisional `blueprintMeta`). The agent then calls `ggui_render` with the `handshakeId` plus `props`: omit `override` to reuse the suggestion's provisional `blueprintId` as-is, or pass `override: {contract?, variance?}` to mint a fresh `blueprintId` against a re-aimed draft.\n\n---\n\n## Agent system prompt\n\nThe canonical posture-only system prompt for any agent calling these tools is exported as a string constant:\n\n```ts\nimport { GGUI_AGENT_SYSTEM_PROMPT } from \"@ggui-ai/protocol\";\n```\n\nUse it as-is. It teaches the wire flow (handshake → render → consume), the three rendering origins, and when to call which tool — without baking in any product-specific persona. Per-tool `description` strings on each `ggui_*` MCP tool reinforce the same flow at the tool layer, so the agent has two consistent signals during planning.\n\nRoll your own system prompt only when you have a domain-specific persona to layer on top. In that case, **concatenate**, don't replace: keep `GGUI_AGENT_SYSTEM_PROMPT` first, then append your additions. Replacing it removes the wire-flow teaching, and the agent will misuse the toolset.\n\nThe prompt source lives at `packages/protocol/src/recommended-prompts.ts` and ships with `@ggui-ai/protocol` for every consumer language the protocol package targets.\n\n---\n\n## Tools\n\n| Tool                                                              | Purpose                                                                                                      |\n| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |\n| [`ggui_handshake`](#ggui_handshake)                               | Negotiate the wire surface before rendering — returns `handshakeId` + a routed `suggestion`.                 |\n| [`ggui_render`](#ggui_render)                                     | Materialize the UI; a `rendered` outcome mints `sessionId`.                                                  |\n| [`ggui_consume`](#ggui_consume)                                   | Long-poll buffered user events on one GguiSession.                                                           |\n| [`ggui_amend`](#ggui_amend)                                       | Repaint the mounted card in place — no new card, history number unchanged.                                   |\n| [`ggui_update`](#ggui_update)                                     | Render the updated state as a NEW card; advances the history number (epoch).                                 |\n| [`ggui_emit`](#ggui_emit)                                         | Push a delivery onto a declared `streamSpec` channel.                                                        |\n| [`ggui_get_session`](#ggui_get_session)                           | Read the GguiSession wire projection — ids, event sequence, timestamps, plus `contextSnapshot` when present. |\n| [`ggui_get_render_source`](#ggui_get_render_source)               | Read the generated source of the calling app's own render.                                                   |\n| [`ggui_list_sessions`](#ggui_list_sessions)                       | Enumerate GguiSessions by host conversation (resume flows).                                                  |\n| [`ggui_list_gadgets`](#ggui_list_gadgets)                         | Fetch the renderer-side gadget catalog before authoring a contract.                                          |\n| [`ggui_list_themes`](#ggui_list_themes)                           | List the theme presets usable via `ggui_render({themeId})`.                                                  |\n| [`ggui_list_featured_blueprints`](#ggui_list_featured_blueprints) | Enumerate builder-curated featured blueprints.                                                               |\n| [`ggui_search_blueprints`](#ggui_search_blueprints)               | Semantic search across this app's blueprints.                                                                |\n| [`ggui_render_blueprint`](#ggui_render_blueprint)                 | Resolve a registered blueprint id to its compiled bundle.                                                    |\n| [`ggui_discover`](#ggui_discover)                                 | Platform capability discovery (hosted-only).                                                                 |\n\n### `ggui_handshake`\n\nNegotiate the wire surface for a UI. Call BEFORE `ggui_render`. The agent posts a draft; the server runs blueprint-search + contract-validation in parallel and returns a routed `suggestion` the agent then accepts or overrides on render.\n\n**Top-level fields:**\n\n| Field            | Type      | Required | Description                                                                                                                                                                                                                                                                                                             |\n| ---------------- | --------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `intent`         | `string`  | Yes      | Concise purpose — same intent across calls = same component reused. Example: `\"Gmail inbox for email triage\"`. The intent is stored with the generated interface and read again when later requests of this app are matched against it, so describe the task, never the end user: no names, emails or personal details. |\n| `blueprintDraft` | `object`  | Yes      | Single-field draft wrapping the agent's `contract` (required) plus optional `variance` and optional `generator` slug hint. The contract drives blueprint-search embed/structural axes; variance feeds the variance axis.                                                                                                |\n| `forceCreate`    | `boolean` | No       | Skip blueprint-search and route straight to validation + agent-mode suggestion against the draft. Use after a prior handshake returned an unwanted cache suggestion.                                                                                                                                                    |\n\n**Returns:** `{ handshakeId, action, suggestion, propsSchema?, propsSchemaHash?, propsSchemaProfile?, nextStep? }`\n\n| Field                | Type     | Description                                                                                                                                                                                                                                                                                                                                                                                                               |\n| -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `handshakeId`        | `string` | Stable id — pass to `ggui_render`. Records are SINGLE-USE and expire after 10 minutes.                                                                                                                                                                                                                                                                                                                                    |\n| `action`             | `enum`   | One of `create` / `reuse` / `update` / `replace` / `declined`.                                                                                                                                                                                                                                                                                                                                                            |\n| `suggestion`         | `object` | Routed suggestion. Carries `origin: 'cache' \\| 'agent' \\| 'synth'`, an always-present provisional `blueprintMeta` (`contractHash` always; `blueprintId`, `codeHash` and `source` only on a cache hit; `matchedIntent`, the matched card's stored intent at most 280 characters, only on a judged semantic hit from the app's own pool), and conditional `amendments` (synth-only) / `validationFindings` (soft on cache). |\n| `propsSchema`        | `object` | The exact JSON Schema the paired `ggui_render` enforces for this handshake (SPEC §2.3.2) — enum fields list their full legal vocabulary. Present when the agreed contract differs from your draft; absent means your draft's propsSpec is agreed verbatim.                                                                                                                                                                |\n| `propsSchemaHash`    | `string` | sha256 (lowercase hex) over the RFC 8785 canonical form of the enforced schema. On every non-declined handshake. A later `contract_violation` carries the hash of the schema it enforced — equal hashes mean the props were at fault.                                                                                                                                                                                     |\n| `propsSchemaProfile` | `string` | `'grammar-safe'` (every keyword in the SPEC's enumerated core — compilable into a decoding grammar) or `'full'` (read the schema as context). Treat unknown values as `'full'`.                                                                                                                                                                                                                                           |\n| `nextStep`           | `object` | Wire-shape recovery hint — `{tool: 'ggui_render', example}` worked-literal of the next call.                                                                                                                                                                                                                                                                                                                              |\n\nThe agent branches the paired `ggui_render` on `suggestion.origin`: any origin can be accepted (reuse the provisional `blueprintId` verbatim) or overridden (mint fresh against a new draft).\n\n### `ggui_render`\n\nMaterialize the UI. Step 3 of the three-step handshake protocol. `handshakeId` and `props` are REQUIRED. Commit relative to the handshake's suggestion by PRESENCE of `override`: omit it to ACCEPT the suggestion as-is, or provide `override: {contract?, variance?}` to re-aim (PATCH semantics).\n\n```json\n// ACCEPT the suggestion as-is\n{ \"handshakeId\": \"h_…\", \"props\": {…} }\n\n// re-draft the contract (cold-gen)\n{ \"handshakeId\": \"h_…\", \"props\": {…}, \"override\": { \"contract\": {…} } }\n\n// re-aim the variant axis\n{ \"handshakeId\": \"h_…\", \"props\": {…}, \"override\": { \"variance\": {…} } }\n```\n\n| Field         | Type     | Required | Description                                                                                                                                                                                                                                                                                                                     |\n| ------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `handshakeId` | `string` | Yes      | From a prior `ggui_handshake` response.                                                                                                                                                                                                                                                                                         |\n| `props`       | `object` | Yes      | Runtime prop values for THIS render. Validated against the effective contract's `propsSpec`; failures fail the render with a recoverable `ContractViolationError`. Pass `{}` when the contract declares no `propsSpec`.                                                                                                         |\n| `themeId`     | `string` | No       | Per-render theme preset override — wins over `App.defaultThemeId` for THIS render. Discover ids via `ggui_list_themes`. Omit to inherit the app theme.                                                                                                                                                                          |\n| `infra`       | `object` | No       | `{model?}` — model route in either wire form — canonical `anthropic:claude-haiku-5-5` or LiteLLM `anthropic/claude-haiku-5-5`; a value that parses in neither fails the handler input parse at `infra.model`. Strict: a typo like `infra.modelId` surfaces as a zod path instead of silently falling back to the default model. |\n| `override`    | `object` | No       | Omit to ACCEPT the suggestion as-is. Provide `{contract?, variance?}` to re-aim: `override.contract` re-drafts the contract (STRICT — must already conform) and resolves at its key — reuse if a card exists there, else cold-gen; `override.variance` re-aims the variant axis.                                                |\n\n**Returns:** `{ outcome, … }` — read `outcome` first; it decides which of the other fields exist.\n\n| Field          | Type     | Description                                                                                                                                                                                                                                                                                                                                             |\n| -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `outcome`      | `enum`   | REQUIRED, always present. `rendered` \\| `failed` \\| `refused` — the discriminant. `rendered`: an interface was produced. `failed`: generation ran and produced none. `refused`: the deployment declined before doing any work. See SPEC §7.1.                                                                                                           |\n| `sessionId`    | `string` | Globally-unique id (UUID) for the delivered render. Present iff `outcome` is `rendered` or `failed`. Use for `ggui_consume` / `ggui_update`.                                                                                                                                                                                                            |\n| `resourceUri`  | `string` | Present iff `outcome` is `rendered` — a failed render commits an error session but exposes no mount. Spec-canonical MCP-Apps entry point, mirrored on `_meta.ui.resourceUri`; no clickable URL on the wire. See [Render locator grammar](/api/mcp-apps/#render-locator-grammar) for the full URI shape.                                                 |\n| `action`       | `enum`   | One of `create` / `reuse` / `update` / `replace` / `declined`. Present iff `outcome` is `rendered` or `failed`.                                                                                                                                                                                                                                         |\n| `contractHash` | `string` | Canonical hash of the rendered data contract (shape only — fields, types, specs). Same hash ⟺ same data flow. Present iff `outcome` is `rendered` or `failed`.                                                                                                                                                                                          |\n| `blueprintId`  | `string` | Opaque id of the materialised component. Equal across two renders means the same cached component was served — or, for two fresh generations at a key bound to other code, byte-identical code (that id is content-addressed and registered nowhere). A fresh generation at a free key mints a new id. Present iff `outcome` is `rendered` or `failed`. |\n| `variantKey`   | `string` | Canonical hash of the design-time variance. With `contractHash` it forms the reuse key. Present iff `outcome` is `rendered` or `failed`.                                                                                                                                                                                                                |\n| `cache`        | `object` | Reuse outcome — `{ hit, similarity?, cachedBlueprintId?, llmCallsAvoided, kind?, reason? }`. Present iff `outcome` is `rendered` or `failed`.                                                                                                                                                                                                           |\n| `nextStep`     | `object` | Emitted ONLY on a `rendered` result whose contract has a non-empty `actionSpec`. Points at `ggui_consume({sessionId})` for the inbound action loop. Pure-display renders get no `nextStep`.                                                                                                                                                             |\n| `error`        | `object` | Present iff `outcome` is `failed` (and the tool result is `isError: true`). `{ code, message }` with the closed enum `PRODUCTION_FAILED \\| VALIDATION_ERROR \\| NO_PLATFORM_KEY \\| NO_CREDENTIALS \\| GENERATION_QUEUE_OVERLOADED`. Failed renders carry no `resourceUri` and no `_meta`.                                                                 |\n| `refusal`      | `object` | Present iff `outcome` is `refused`, and then it is the ONLY field beside `outcome`. `{ code, message, fix, retry, handshake: \"intact\", balanceCentsAtCheck? }` — see [Refused (pre-generation)](#refused-pre-generation).                                                                                                                               |\n\nThe render consumes the handshake record — except on a refusal, which consumes nothing. Bootstrap credentials (`wsUrl`, `wsToken`, `expiresAt`) reach the iframe via the `_meta[\"ai.ggui/render\"]` slice, not via this response.\n\n#### Refused (pre-generation)\n\nA refusal is the THIRD outcome, not a failure: the deployment declined the call **before it did any work**, so there is no session to report on. `ggui_render` returns a `tools/call` result with `isError: true` and `structuredContent` of exactly `{ outcome: \"refused\", refusal }` — every identity field, `error` and `nextStep` are absent, and there is no `_meta`. Nothing was read, nothing was committed, no model spend — the SDK has already checked the call against the declared `inputSchema`, so this is not a claim that nothing was validated.\n\n```json\n{\n  \"outcome\": \"refused\",\n  \"refusal\": {\n    \"code\": \"insufficient_credit\",\n    \"message\": \"The balance this app is funded from is exhausted (checked at render time).\",\n    \"fix\": \"The app owner adds funds; the same handshakeId then renders.\",\n    \"retry\": \"after-fix\",\n    \"handshake\": \"intact\",\n    \"balanceCentsAtCheck\": 0\n  }\n}\n```\n\n`refusal.code` comes from `PRE_GENERATION_REFUSAL_CODES`, the closed refusal registry in `@ggui-ai/protocol`; a code outside the `render-gate` subset fails the wire enum loudly. `retry` is one of `after-fix` | `next-period` | `later` | `never`, and each `after-fix` registry row names a `fixBy` — the party who can act. **An agent MUST NOT auto-retry an `after-fix` refusal whose `fixBy` is not `caller`**: the fix belongs to someone else and retrying does not perform it. `refusal.handshake` is always `\"intact\"` — the handshake was never read, so the same `handshakeId` is valid on a retry once the fix lands. Full envelope, hook rule and registry semantics: SPEC §7.1 \"Refusal envelope\".\n\n### `ggui_consume`\n\nLong-poll for buffered user events on one render. Events drain on read (consume-once semantics). Call this right after every `ggui_render` whose response carries `nextStep.tool === 'ggui_consume'`.\n\n| Field       | Type     | Required | Description                                                                                                                                                                                                                                                                        |\n| ----------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `sessionId` | `string` | Yes      | Render to consume from. Globally unique.                                                                                                                                                                                                                                           |\n| `timeout`   | `number` | No       | Long-poll seconds — integer in `[0, 25]`; `0` = immediate (default). Values outside `[0, 25]` reject `INVALID_PARAMS`. Returns on the first event or at timeout; re-call on empty to keep waiting — a longer wait is your loop, not a bigger timeout (pick 5–15s typical, 25 max). |\n\n**Returns:** `{ events: ConsumeEventEntry[], status: \"active\" | \"expired\", client?, nextStep? }`\n\nKeyed by `sessionId`. THE LOOP: when `events` is non-empty, react (commonly via `ggui_amend` to refresh the iframe), then re-call `ggui_consume`. A non-empty drain says so itself: the result carries `nextStep: { tool: 'ggui_amend', description, example, args: { sessionId } }`, and its text leads with the same copy-paste example, so the agent is told twice. It means repaint this card in place on the same `sessionId`, with `kind: 'replace'` and the full props or `kind: 'merge'` and a patch; a new render is the exception, for a milestone worth its own card in the transcript. `nextStep` is absent on an empty drain and on a late drain from an expired render, where the amend it would point at cannot succeed. Exit when you have the events you need — that is the real terminal condition. `status: \"expired\"` is the other exit and means no more events will arrive, but it only fires when a render's TTL actually elapses; on hosted GGUI the default retention is effectively indefinite, so treat `expired` as a self-hosted case rather than something to wait for. The optional `client` field echoes mid-render host observations (window resize, fullscreen toggle, etc.) without forcing a fresh handshake.\n\n### `ggui_update`\n\nRender the session's updated state as a **new card in the conversation — a new history entry**. The history number (epoch) advances by one; the previous card becomes a frozen history record. Use for milestones worth showing in the transcript, or when the original card is no longer visible or usable. Discriminated on `kind`.\n\n```json\n// FULL replacement\n{ \"sessionId\": \"…\", \"kind\": \"replace\", \"props\": { … } }\n\n// RFC 7396 JSON Merge Patch\n{ \"sessionId\": \"…\", \"kind\": \"merge\",   \"patch\": { … } }\n```\n\n| Field       | Type     | Required   | Description                                                                                                                                               |\n| ----------- | -------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `sessionId` | `string` | Yes        | The render to mutate (UUID from `ggui_render` response).                                                                                                  |\n| `kind`      | `enum`   | Yes        | `'replace'` — the `props` map IS the new state. `'merge'` — apply RFC 7396 JSON Merge Patch (null deletes a key; arrays fully replace, NOT element-wise). |\n| `props`     | `object` | If replace | Full replacement props map. Required when `kind: 'replace'`.                                                                                              |\n| `patch`     | `object` | If merge   | RFC 7396 patch. Required when `kind: 'merge'`.                                                                                                            |\n\n**Returns:** `{ sessionId, updated, resourceUri, epoch, warning?, propsSchemaHash?, propsSchemaProfile? }` — `resourceUri` is the epoch-pinned URI of the new record (`ui://ggui/render/{sessionId}#N`); `epoch` is the head after the call. A no-op (`updated: false` + `warning`) mints nothing: bare head URI, epoch unchanged. `propsSchemaHash` + `propsSchemaProfile` (present when the session declares a `propsSpec`) attest the enforced schema this mutation was validated against — equal to the handshake's `propsSchemaHash` under the session-continuity guarantee.\n\nBoth modes validate the final props state (post-merge for `merge`) against the render's `propsSpec` and reject on violation. For a structurally different surface, handshake + render a fresh session instead.\n\n### `ggui_amend`\n\nRepaint the **already-mounted card** in place — no new card appears and the history number does not advance. This is the default move in the gesture loop (`consume → domain-tool → ggui_amend`): the card the user is looking at updates without losing scroll position, focus, or uncommitted input. Same input union as `ggui_update` (SPEC §7.1.2.1).\n\n**Returns:** `{ sessionId, updated, resourceUri, warning?, propsSchemaHash?, propsSchemaProfile? }` — `resourceUri` is the bare live-head URI; there is no epoch field by construction. The two schema-attestation fields carry `ggui_update`'s exact semantics.\n\n`ggui_amend` is not a UI-bound tool: its results carry no `_meta`, hosts never mint a view for them, and the mounted card receives the new props over the live channels (WS / SSE / polling / bridge-pull). Git reading: `ggui_update` = commit, `ggui_amend` = commit --amend.\n\n### `ggui_emit`\n\nEmit a new delivery on a declared `streamSpec` channel of the render. The agent describes WHAT new data exists; the server stamps the canonical `StreamEnvelope` (mode derived from `streamSpec[channel].mode`, `seq` + `timestamp` server-assigned). Validates `payload` against the channel's declared `schema` and rejects undeclared channels at call time.\n\n| Field       | Type      | Required | Description                                                                                                                                                  |\n| ----------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `sessionId` | `string`  | Yes      | Render to stream to. Server enforces app-ownership.                                                                                                          |\n| `channel`   | `string`  | Yes      | Channel name declared on the render's `streamSpec`. Undeclared channels reject.                                                                              |\n| `payload`   | `unknown` | Yes      | Delivery payload. Validated against `streamSpec[channel].schema`.                                                                                            |\n| `complete`  | `boolean` | No       | Terminal-delivery marker. Only valid when the channel was declared with `complete: true` on the streamSpec; setting it on a non-completable channel rejects. |\n\n**Returns:** `{ accepted }`\n\n`accepted: true` means the server validated and enqueued the envelope at the boundary. No-subscriber is NOT an error — buffered retention and live fan-out happen independently. The server-assigned `seq` is observable on the delivered `StreamEnvelope` (the live-channel `data` WS frame), not on this tool result.\n\n### `ggui_get_session`\n\nRetrieve full render state — the wire projection — variant, ids, event sequence, lifecycle timestamps — plus the last-known contextSpec values (`contextSnapshot`) when the render has them. Bumps the activity heartbeat on every successful read. Omits `componentCode` + `sourceCode` (those live on the renderable surface, not the agent-visible one).\n\n| Field       | Type     | Required | Description             |\n| ----------- | -------- | -------- | ----------------------- |\n| `sessionId` | `string` | Yes      | GguiSession to inspect. |\n\n**Returns** (`gguiGetSessionOutputSchema` — the protocol owns the schema; nothing outside this table travels):\n\n| Field             | Type                    | Required | Description                                                                                                                                                           |\n| ----------------- | ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `variant`         | `'render' \\| 'mcpApps'` | Yes      | Which mount the session is; an MCP-Apps mount projects the same base fields from its store row.                                                                       |\n| `id`              | `string`                | Yes      | Render ID.                                                                                                                                                            |\n| `appId`           | `string`                | Yes      | Owning app ID.                                                                                                                                                        |\n| `eventSequence`   | `number`                | Yes      | Server event sequence for this render.                                                                                                                                |\n| `createdAt`       | `number` (epoch ms)     | Yes      | Creation timestamp.                                                                                                                                                   |\n| `lastActivityAt`  | `number` (epoch ms)     | Yes      | Last-seen activity timestamp (post-heartbeat).                                                                                                                        |\n| `expiresAt`       | `number` (epoch ms)     | Yes      | Expiration timestamp (post-heartbeat).                                                                                                                                |\n| `contextSnapshot` | `object`                | No       | The last-known value of every contextSpec slot (via `ggui_runtime_sync_context`); present iff a component (`render`) mount's row has one — never on an mcpApps mount. |\n\n### `ggui_get_render_source`\n\nRead the generated source of the **calling app's own** render. This is the agent's window into what it actually shipped — the TSX, the contract that came out of negotiation, and the live prop values. Unlike `ggui_get_session`, reading source is not an activity signal, so this call never bumps the TTL heartbeat.\n\n| Field       | Type     | Required | Description                               |\n| ----------- | -------- | -------- | ----------------------------------------- |\n| `sessionId` | `string` | Yes      | The render whose source you want to read. |\n\n**Returns:** `{ sessionId, blueprint: { source, contract?, fixtureProps? } }`\n\n| Field                    | Description                                                                                                                                           |\n| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `blueprint.source`       | TSX with a default-exported React component.                                                                                                          |\n| `blueprint.contract`     | Optional — the `DataContract` envelope reassembled from the render's `propsSpec` / `actionSpec` / `streamSpec` / `contextSpec`, when any are present. |\n| `blueprint.fixtureProps` | Optional — the render's live prop values, when present. A natural preview-props snapshot.                                                             |\n\nOnly a component-variant render has source. Four cases answer a not-found or typed no-source error rather than an empty string: a render created by **another app** (cross-tenant and unknown `sessionId` surface identically, so existence is never leaked), an unknown `sessionId`, an MCP-Apps or system-card variant, and a render not yet committed.\n\n### `ggui_list_sessions`\n\nEnumerate this app's GguiSessions by host conversation — the lookup behind resume flows. Matches on the `_meta[\"ai.ggui/host-session\"]` pair (`hostName` + `hostSessionId`) captured at render creation; sessions created without that slice never match host-scoped queries.\n\n| Field           | Type     | Required | Description                                                                                                 |\n| --------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------- |\n| `hostName`      | `string` | No       | Filter by host identifier (`claude.ai`, `sample`, …). Pair with `hostSessionId` to target one conversation. |\n| `hostSessionId` | `string` | No       | The host's opaque conversation-grouping key (e.g. a claude.ai thread id). Typically paired with `hostName`. |\n| `limit`         | `number` | No       | Max rows, 1–200. Default 50. Newest-last ordering matches the conversation timeline.                        |\n\n**Returns:** `{ sessions: [{ sessionId, hostName?, hostSessionId?, createdAt, lastActivityAt, status, wsToken?, wsTokenExpiresAt? }] }`\n\n`createdAt` / `lastActivityAt` are ISO 8601 strings here. The `wsToken` pair is present only when the deployment wires a `mintWsToken` seam — resume flows use it to remount each iframe without a fresh handshake.\n\n:::note[Removed in `draft-2026-05-27`]\nThe legacy session-lifecycle and stack-manipulation tools were deleted in the Phase B render-identity collapse. The session vessel and stack-of-N concept they served no longer exist; renders are single-mount durable records with no close ceremony.\n:::\n\n### `ggui_list_gadgets`\n\nReturn the catalog of renderer-side **gadgets** the UI may import via the package-keyed `clientCapabilities.gadgets` map of a `DataContract`. Call this BEFORE authoring a contract so the catalog you seed only references gadgets the renderer will actually serve. Returns the per-app catalog: the 7-hook stdlib package (`@ggui-ai/gadgets`) is the structural floor; gadgets declared in `ggui.json#app.gadgets` layer on top (declared wins on a package collision).\n\n| Field   | Type     | Required | Description                                                                                                                                    |\n| ------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |\n| `appId` | `string` | No       | The app whose catalog to fetch. Defaults to the caller-resolved appId from the auth header. Explicit mismatch surfaces as `app_access_denied`. |\n\n**Returns:** `{ gadgets: GadgetDescriptor[] }`\n\nEach `GadgetDescriptor` is a gadget PACKAGE: `{ package, version, exports: GadgetExport[], … }` — package-level identity plus transport metadata (`bundleUrl` / `bundleHost` / `bundleSri` / `styleUrl` / `connect` / `requires` / `typesUrl` / `typesSri` for non-stdlib packages). Each `GadgetExport` is a field-presence-discriminated union — a hook export `{ hook, description?, usage?, example?, gotchas?, permission?, required? }` or a component export `{ component, description?, usage?, example?, gotchas?, permission?, required? }`. Full entry shape: [SDK gadgets guide](/sdk/gadgets/).\n\n### `ggui_list_themes`\n\nReturn the theme presets an agent may apply per render via `ggui_render({ themeId })`. When the app configures an `availableThemeIds` allowlist, the catalog is filtered to it (catalog order preserved; unregistered ids silently dropped).\n\n| Field   | Type     | Required | Description                                                                                                                                          |\n| ------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `appId` | `string` | No       | The app whose theme catalog to fetch. Defaults to the caller-resolved appId from the auth header. Explicit mismatch surfaces as `app_access_denied`. |\n\n**Returns:** `{ themes: [{ id, name, description, modes }] }`\n\n`modes` lists the variants each preset ships (`light` / `dark`).\n\n### `ggui_list_featured_blueprints`\n\nEnumerate the builder-curated featured blueprints declared via the server's blueprint catalog (typically `ggui.json#blueprints.include` for OSS deployments). Returns an empty list when no catalog is wired.\n\n**Inputs:** none.\n\n**Returns:** `{ blueprints: BlueprintEntry[], total }`\n\nPair with `ggui_search_blueprints` for semantic lookup or `ggui_render_blueprint` to materialize one directly.\n\n### `ggui_search_blueprints`\n\nSemantic search across this app's blueprints — both manifest-declared UIs (`ggui.json#blueprints.include`) and previously cached generations. Matches by name/description against the manifest source and by cosine similarity against the semantic vector index; results merge + dedupe by id (manifest wins on collision) and sort by score descending.\n\n| Field   | Type     | Required | Description                                                |\n| ------- | -------- | -------- | ---------------------------------------------------------- |\n| `query` | `string` | Yes      | Natural-language description of the UI you're looking for. |\n| `limit` | `number` | No       | Max results. Default 10. Maximum 100.                      |\n\n**Returns:** `{ results, total, query }`\n\nEach result row carries `{ id, name, description, category, props, callbacks, featured, relevance: 'match', score }`. `score` is 0–1 (cosine similarity for semantic hits; `1.0` for exact manifest-name matches, `0.7` for manifest substring matches). Agents use `score` to decide whether to reuse a blueprint or generate from scratch.\n\n### `ggui_discover`\n\n:::note[Hosted-only]\nRegistered on hosted `mcp.ggui.ai`. Self-hosted `ggui serve` omits this tool — the same info is available statically from the OSS package metadata.\n:::\n\nReturn platform capabilities (protocol version, supported content types, shell types, adapter types, component-capability catalog) and — when the bearer token resolves to a known app — that app's enabled adapters / granted capabilities / auth mode / rate limit. Call BEFORE the first handshake when you need to branch on what this deployment supports.\n\n**Inputs:** none.\n\n**Returns:** `{ protocolVersion, contentTypes, shellTypes, adapterTypes, componentCapabilities, app? }`\n\n| Field                   | Type       | Description                                                                                                                                                                                 |\n| ----------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `protocolVersion`       | `string`   | GGUI protocol revision (prelaunch drafts use `draft-YYYY-MM-DD`; first frozen release will be `1.0.0`).                                                                                     |\n| `contentTypes`          | `string[]` | Bundle content types this deployment serves (e.g. `application/javascript+react`).                                                                                                          |\n| `shellTypes`            | `string[]` | Available shell flavors (`chat`, `fullscreen`, `spatial`).                                                                                                                                  |\n| `adapterTypes`          | `string[]` | Adapter families wired on this deployment (`voice`, `camera`, `location`, `bluetooth`).                                                                                                     |\n| `componentCapabilities` | `string[]` | Informational capability vocabulary. The load-bearing per-app grant lives on the operator-registered `GadgetExport.permission` in `App.gadgets` (registry side) — not on the contract wire. |\n| `app`                   | `object?`  | Present when the bearer token resolves to a known app. `{ enabledAdapters?, grantedCapabilities?, defaultShellType?, authMode?, rateLimitPerMinute? }`.                                     |\n\n### `ggui_render_blueprint`\n\nResolve a registered blueprint id to its compiled JS bundle, inline. The OSS handler reads the manifest entry via the server's `UiRegistry`, compiles on demand from the colocated TSX (`@ggui-ai/dev-stack::LocalUiRegistry` is the reference impl), and returns the bundle as a single JSON field. Fails with a clear error when the id is unknown or no bundle is available. Only registered when the server boots with a `UiRegistry` seam — otherwise the tool is omitted from `tools/list` entirely.\n\n| Field         | Type     | Required | Description                                                                                           |\n| ------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------- |\n| `blueprintId` | `string` | Yes      | Stable blueprint id declared via `ggui.ui.json#id`. Must match an entry in this server's UI registry. |\n\n**Returns:** `{ blueprintId, blueprintName, code, contentType }`\n\n`code` is the compiled JS bundle as a string (ESM `export default` producing the component to mount). `contentType` is typically `'application/javascript+react'` — pinned by the server's compile pipeline. The caller mounts `code` directly; no second round-trip is required.\n\n---\n\n## Events\n\nTwo distinct shapes are in play. Don't conflate them:\n\n- **`ActionEnvelope`** — live-channel inbound on the WebSocket subscribe seam. Used by browser/SDK consumers that listen to live events (e.g. `@ggui-ai/wire`'s `useRender`). See [WebSocket Protocol](/api/websocket-protocol/).\n- **`ConsumeEventEntry`** — per-gesture row on the render-keyed consume pipe, returned by `ggui_consume`. This is what agents read.\n\n### `ActionEnvelope` (live-channel inbound)\n\n```typescript\ninterface ActionEnvelope<TPayload = JsonValue> {\n  sessionId: string;\n  type: EventType;\n  payload?: TPayload; // For `data:submit`: { action, data?, tool? }\n  clientSeq?: number; // client-monotonic, for at-least-once dedup\n}\n```\n\n### `ConsumeEventEntry` (consume pipe)\n\n```typescript\ninterface ConsumeEventEntry {\n  readonly type: \"action\";\n  readonly sessionId: string;\n  readonly intent: string; // which actionSpec[*] fired\n  readonly actionData: JsonValue | null; // matches actionSpec[intent].schema\n  readonly uiContext: JsonObject; // contextSpec slot snapshot at gesture time\n  readonly actionId: string; // 8-hex FNV-1a correlation id\n  readonly firedAt: string; // ISO 8601 UTC\n}\n```\n\nBoth envelopes are flat — no nested `event` / `context` / `meta` blocks. Diagnostic render metadata (device info, interface context) lives on the render at subscribe time, not per-delivery.\n\n### Event Types\n\n`EventType` has exactly one member, `data:submit`.\n\n| Type          | Category | Description                              |\n| ------------- | -------- | ---------------------------------------- |\n| `data:submit` | Data     | User gesture surfaced as a consume event |\n\nThe pre-actionSpec multi-event vocabulary (`data:change`, `lifecycle:*`, `interaction:*`, `error:*`) was deleted in draft-2026-06-12 — it never had a first-party producer. Today's actionSpec-driven flow surfaces every user gesture as a `data:submit` `ConsumeEventEntry`. There is no other event vocabulary — agent code reads from `ggui_consume` and only needs to recognize the `data:submit` shape.\n\n---\n\n## Error Codes\n\nNames mirror the `MCP_ERROR_CODES` / `PLATFORM_ERROR_CODES` constants exported from `@ggui-ai/protocol`. The `-32010` range is the GGUI platform-extension block, not part of the core protocol.\n\n| Code     | Name                    | Description                                                                                                           |\n| -------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------- |\n| `-32700` | `PARSE_ERROR`           | Invalid JSON in request                                                                                               |\n| `-32600` | `INVALID_REQUEST`       | Not a valid JSON-RPC object                                                                                           |\n| `-32601` | `METHOD_NOT_FOUND`      | Unknown method name                                                                                                   |\n| `-32602` | `INVALID_PARAMS`        | Missing or invalid tool arguments                                                                                     |\n| `-32603` | `INTERNAL_ERROR`        | Server-side failure                                                                                                   |\n| `-32007` | `UNAUTHORIZED`          | Invalid token or app ID                                                                                               |\n| `-32002` | `SESSION_NOT_FOUND`     | Session expired or deleted                                                                                            |\n| `-32003` | `APP_NOT_FOUND`         | The endpoint no longer serves this app — unknown, or deprovisioned (then HTTP `403` with `data.refusal`; see below)   |\n| `-32006` | `MOUNT_UNAVAILABLE`     | Render locator resolves to no mount                                                                                   |\n| `-32014` | `GENERATION_OVERLOADED` | Platform: the generation queue is saturated — HTTP `503` with `Retry-After`; the server, not the caller, is the limit |\n\n`-32004` is retired-reserved: `PRODUCTION_FAILED` is now an **in-result `-32013` is retired-reserved likewise (GGUI#890): the render-rate caps are returned refusals (`app_rate_limited`, `issuer_rate_limited`) and no first-party server ever emitted a transport-level rate-limit error after #886. `-32005`, `-32010`, `-32011`, `-32012` and `-32020` are retired-reserved likewise (GGUI#910): no first-party server ever emitted them; quota and concurrency limits, where a deployment has them, are returned refusals or implementation-range codes, and a contract violation is the Plane-2 `contract_violation` result (§7.9) or the live channel's `CONTRACT_VIOLATION` frame code.\ntool-error code**. A failed generation returns a `tools/call` result with\n`isError: true` and schema-conformant `structuredContent` carrying\n`error: { code, message }` (closed enum `PRODUCTION_FAILED |\nVALIDATION_ERROR | NO_PLATFORM_KEY | NO_CREDENTIALS |\nGENERATION_QUEUE_OVERLOADED`), with no\n`resourceUri` and no `_meta` — a failed render is not mountable. The\nerror session is still committed, so the live channel and render\nresource keep the failure message. See SPEC §7.9 Plane 3.\n\n`-32006 MOUNT_UNAVAILABLE` answers a `resources/read` of a render\nlocator that identified the render but cannot return a mount for it —\nthe component behind it is gone, the server keeps no durable record to\nrestore from, or nothing can deliver it. The closed classification\n(`NOT_FOUND | BLUEPRINT_UNRESOLVABLE | NOT_SUPPORTED | NOT_MOUNTABLE`)\nrides on `error.data.code`; `NOT_FOUND` rides on `-32002` instead. See\nSPEC §7.10 for the read contract.\n\n`-32003 APP_NOT_FOUND` is also the **endpoint-level refusal** — the one refusal that happens before any tool is called, at the per-app MCP endpoint's authorization, on any JSON-RPC request from an authenticated caller bound to the app, `initialize` included. _Bound_ means: for a federated identity, a signature-verified token whose `aud` equals the endpoint's `{appId}` and whose `iss` equals the app's federation issuer; for a native key, a key whose bound app (if any) equals `{appId}`. Identity comes first: an anonymous request is refused `401` with `-32007` by the auth adapter before this arm and learns nothing about the app, and a caller bound to a different app gets the bare `403` before any lookup. The refusal is the app's state, not the caller's kind — a deprovisioned app answers it to every bound caller, federated or native, and answers it before the subject's own deny status is read, so a revoked subject learns an id it already holds and nothing else. The answer is HTTP `403` with a JSON-RPC error whose `code` is `-32003`, whose `message` is `App not found`, and whose `data.refusal` is the registry projection `{ code, message, fix, retry, appId }` — today exactly `code: \"app_deprovisioned\"`, `retry: \"never\"`. `appId` is non-empty and equals the endpoint path's `{appId}` — the app the refused endpoint serves, the id the bound caller already holds — carried as data so a client's repair loop keys on it instead of parsing the message. `data` is strict; `fixBy` never travels (a client reads it from the registry by `code`). Observable: the conformance kit's `transport-refusal` catalog grades the projection (`appId` present and equal to its input); equality with the path is a route-level obligation the kit cannot see, observed by the deployment's own route test; an emitter whose projection fails the strict parse degrades to the untyped arm and logs `error_mapper_failed`. An authorization failure that is not a registry state — no issuer, a foreign issuer, a denied subject on a live app — answers a bare `403` with `-32007` and no `data`, indistinguishable among themselves by contract; a deprovisioned app is the one refusal with a tenant-side fix, which is why it is the one that must be legible. See SPEC §7.1, \"Endpoint-level refusal\".\n\n---\n\n## Supported Models\n\nThere is no fixed model menu. The operator sets the per-app default via `ggui.json#generation.model` — any provider-prefixed route, written `provider:model` (canonical) or LiteLLM-style `provider/model`, e.g. `anthropic:claude-haiku-5-5`. Providers on the self-hosted BYOK path: `anthropic`, `openai`, `google`, `openrouter` (`bedrock` routes are hosted-runtime-only and rejected by `ggui serve`).\n\nAgents can override the model per render via `ggui_render({ infra: { model } })` with a model route in either wire form — canonical `anthropic:claude-haiku-5-5` or LiteLLM `anthropic/claude-haiku-5-5`; a value that parses in neither fails the handler input parse at `infra.model` (a bare model id is one such value). The protocol registry is the allowlist. Its lineup, the models a picker shows first, is `claude-haiku-5-5`, `claude-sonnet-5-5`, `claude-opus-5-5` and `claude-fable-5-1` (the most capable) for Anthropic, and `gpt-6-luna` for OpenAI. The registry also accepts the other GPT-6 ids (`gpt-6-astra`, `gpt-6-sol`, `gpt-6.1-sol`), the GPT-5.6 tiers (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`), `gpt-5.3-codex` and the GPT-5.4 ids, and it still routes the legacy models (`claude-haiku-4-5`, `claude-sonnet-5`, `claude-opus-5`, `claude-fable-5` and the 4.6–4.7 generation); anything outside the registry is rejected at parse time. On hosted GGUI, a pool-funded render that names no model — no `generation.model`, no `infra.model` — runs on the pool default, `openai/gpt-6-luna`; any other model on the pool needs a rate row for your account, or your own provider key (see [Premium models](/hosted/billing/#premium-models)).\n\n---\n\n## Example: Full Render Flow (curl)\n\nThis walkthrough runs against self-hosted `ggui serve` (started with `--dev-allow-all`). Hosted `mcp.ggui.ai` speaks the same wire — only the URL and bearer change.\n\n```bash\n# 1. Initialize\ncurl -X POST http://127.0.0.1:6781/mcp \\\n  -H \"Authorization: Bearer dev\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\",\n    \"params\": {\n      \"protocolVersion\": \"2025-06-18\",\n      \"clientInfo\": { \"name\": \"curl\", \"version\": \"1.0\" },\n      \"capabilities\": {}\n    }\n  }'\n\n# 2. Handshake — negotiate the wire surface\n#    (blueprintDraft carries the agent's contract)\ncurl -X POST http://127.0.0.1:6781/mcp \\\n  -H \"Authorization: Bearer dev\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"jsonrpc\": \"2.0\", \"id\": 2, \"method\": \"tools/call\",\n    \"params\": {\n      \"name\": \"ggui_handshake\",\n      \"arguments\": {\n        \"intent\": \"Contact form\",\n        \"blueprintDraft\": {\n          \"contract\": { \"propsSpec\": {}, \"actionSpec\": {} }\n        }\n      }\n    }\n  }'\n\n# 3. Render — accept the handshake suggestion verbatim (mints sessionId)\ncurl -X POST http://127.0.0.1:6781/mcp \\\n  -H \"Authorization: Bearer dev\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"jsonrpc\": \"2.0\", \"id\": 3, \"method\": \"tools/call\",\n    \"params\": {\n      \"name\": \"ggui_render\",\n      \"arguments\": { \"handshakeId\": \"hs_…\", \"props\": {} }\n    }\n  }'\n\n# 4. Poll for events (keyed by sessionId from step 3)\ncurl -X POST http://127.0.0.1:6781/mcp \\\n  -H \"Authorization: Bearer dev\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"jsonrpc\": \"2.0\", \"id\": 4, \"method\": \"tools/call\",\n    \"params\": {\n      \"name\": \"ggui_consume\",\n      \"arguments\": { \"sessionId\": \"<sessionId from step 3>\", \"timeout\": 15 }\n    }\n  }'\n\n# No explicit close — the render is a durable record; you just stop addressing it.\n```\n\n---\n\n## See Also\n\n- [Protocol overview](/protocol/overview/) — the three channels at a glance\n- [WebSocket Protocol](/api/websocket-protocol/) — live-channel live events\n- [MCP Apps](/api/mcp-apps/) — inline rendering inside MCP hosts\n- [OAuth on mcp.ggui.ai](/self-hosted/oauth/) — hosted auth ceremony\n- [Control MCP plane](/api/control-mcp/) — design-time and operator agent tools at `/control`\n- [Docs MCP route](/api/mcp-docs/) — anonymous docs search at `/docs`\n- [Playground · Todos](/hosted/playgrounds/todos/) — hosted demo service at `/playground/todos`\n- [Playground · MDH](/hosted/playgrounds/mdh/) — hosted demo service at `/playground/the-million-dollar-homepage`\n- [MCP services](/architecture/mcp-services/) — mounting standalone services on one server\n- [Glossary](/glossary/) — gadget / tool / blueprint, plus every other term"},{"path":"/api/websocket-protocol.md","title":"WebSocket Protocol","description":"Live-channel wire format — the live session plane between a GGUI client and your self-hosted `ggui serve`, or hosted GGUI.","body":"---\ntitle: WebSocket Protocol\ndescription: Live-channel wire format — the live session plane between a GGUI client and your self-hosted `ggui serve`, or hosted GGUI.\n---\n\nThe live channel — the **live session plane** — runs over a WebSocket between a GGUI server and a connected client. It carries agent `render` notifications, outbound `StreamEnvelope` deliveries, and canonical inbound `ActionEnvelope` user actions.\n\n| Deployment                 | URL                                              |\n| -------------------------- | ------------------------------------------------ |\n| Self-hosted (`ggui serve`) | `ws://127.0.0.1:6781/ws` (default; configurable) |\n| Hosted GGUI                | `wss://mcp.ggui.ai/ws`                           |\n\n:::note[You probably don't write this yourself]\nYou normally don't write this wire: `@ggui-ai/iframe-runtime` (booted inside the MCP-Apps iframe, mounted by `<AppRenderer>` from `@mcp-ui/client`) handles connect, subscribe, resume, and reconnect — on every host, first-party included. Render semantics live in the mounted view, not in host code; `@ggui-ai/mcp-apps-react`'s `useMcpAppsChat` + `<AppRenderer>` mount path is the web host helper, not a renderer of its own. Agents talk to GGUI over the [MCP HTTP API](/api/mcp-protocol/), not the WebSocket. Read on only if you're building a custom client or debugging traffic.\n:::\n\n## Frame catalog\n\n| Direction       | Type                    | Payload                               | Purpose                                                                                                                                                                                                            |\n| --------------- | ----------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| Client → Server | `subscribe`             | `SubscribePayload`                    | Bind the connection to a GguiSession. MUST be first.                                                                                                                                                               |\n| Client → Server | `action`                | `ActionEnvelope`                      | Canonical inbound user action.                                                                                                                                                                                     |\n| Client → Server | `ping`                  | —                                     | Heartbeat; server answers `pong`.                                                                                                                                                                                  |\n| Client → Server | `channel_subscribe`     | channel name                          | Subscribe to a `streamSpec[*].source.tool` channel; server polls the tool.                                                                                                                                         |\n| Client → Server | `channel_unsubscribe`   | channel name                          | Cancel a `channel_subscribe` (idempotent).                                                                                                                                                                         |\n| Client → Server | `host_context_observed` | host-context projection               | Iframe echoes the MCP-Apps host context.                                                                                                                                                                           |\n| Server → Client | `ack`                   | `AckPayload`                          | Acknowledges `subscribe`; seeds resume cursors.                                                                                                                                                                    |\n| Server → Client | `pong`                  | —                                     | Heartbeat response.                                                                                                                                                                                                |\n| Server → Client | `error`                 | `ErrorPayload`                        | Transport / auth / subscribe-level error.                                                                                                                                                                          |\n| Server → Client | `render`                | `RenderPayload {session, matchType?}` | The agent committed a new GguiSession.                                                                                                                                                                             |\n| Server → Client | `props_update`          | `{sessionId, props, epoch?}`          | `ggui_update` / `ggui_amend` fan-out — full props replacement.                                                                                                                                                     |\n| Server → Client | `data`                  | `StreamEnvelope`                      | Outbound delivery on a declared `streamSpec` channel. Generation-pipeline progress also flows here as a `{type:'data'}` delivery on the reserved `_ggui:lifecycle` channel — there is no dedicated progress frame. |\n| Server → Client | `render_event`          | `GguiSessionEvent`                    | Event-ledger replay when `subscribe.sinceSequence` is set.                                                                                                                                                         |\n| Server → Client | `drain_ack`             | `DrainAckPayload`                     | `ggui_consume` drained an action; iframe cancels its claim timer.                                                                                                                                                  |\n| Server → Client | `channel_payload`       | channel frame                         | `source.tool` result for a subscribed channel.                                                                                                                                                                     |\n| Server → Client | `channel_error`         | channel error frame                   | Channel subscribe rejected / poll failed / tool errored.                                                                                                                                                           |\n\n## Client → Server\n\n### `subscribe`\n\nBind the connection to a GguiSession. MUST be the first message.\n\n```json\n{\n  \"type\": \"subscribe\",\n  \"payload\": {\n    \"sessionId\": \"ses_abc123\",\n    \"appId\": \"app_myapp\",\n    \"wsToken\": \"btkn_…\"\n  }\n}\n```\n\n| Field               | Required | Description                                                                                                                                   |\n| ------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |\n| `sessionId`         | Yes      | The GguiSession to bind.                                                                                                                      |\n| `appId`             | No       | Tenancy scope — when present MUST match the session's bound app; when omitted the server resolves the identity-default appId.                 |\n| `wsToken`           | No       | Short-TTL auth credential from the `_meta[\"ai.ggui/render\"]` slice. Required unless the connection authenticated by bearer (see below).       |\n| `fromSeq`           | No       | Per-channel stream cursor — replay outbound `StreamEnvelope`s with `seq > N` before the live tail begins (needs a `GguiSessionStreamBuffer`). |\n| `fromEpoch`         | No       | The `streamEpoch` your `fromSeq` was counted in. Send it with `fromSeq`; if it is not the session's current epoch, the server replays all.    |\n| `sinceSequence`     | No       | Event-ledger replay cursor — replays `GguiSessionEvent`s with `sequence > N` as `render_event` frames. Independent of `fromSeq`.              |\n| `role`              | No       | `'user'` or `'agent'`.                                                                                                                        |\n| `supportedVersions` | No       | Protocol-version handshake — see below.                                                                                                       |\n\n`fromSeq` and `sinceSequence` are two independent replay cursors over two ledgers: per-channel stream replay vs the render-level event ledger. When both are set, `sinceSequence` events replay first.\n\n#### Authentication\n\nThe load-bearing credential is the `wsToken` minted at `ggui_render` and delivered on the `_meta[\"ai.ggui/render\"]` slice. Clients thread it twice: as `?wsToken=<encoded>` on the WebSocket upgrade URL AND inside `SubscribePayload.wsToken`. It is opaque, validated server-side against `sessionId` + `appId`, short-TTL, and reusable within its TTL for reconnects. A reconnect presents a live `wsToken` again; the server mints no reconnect credential into the ack.\n\n### `action`\n\nA canonical [`ActionEnvelope`](/protocol/envelopes/#actionenvelope) — flat, no nested blocks.\n\n```json\n{\n  \"type\": \"action\",\n  \"payload\": {\n    \"sessionId\": \"ses_abc123\",\n    \"type\": \"data:submit\",\n    \"payload\": { \"action\": \"submit\", \"data\": { \"rating\": 5 } },\n    \"clientSeq\": 1\n  }\n}\n```\n\nThe `sessionId` identifies the render the action originated from; the server rejects envelopes whose `sessionId` doesn't match the subscriber's bound render.\n\n---\n\n## Server → Client\n\n### `ack`\n\nAcknowledges the `subscribe` and seeds resume cursors.\n\n```json\n{\n  \"type\": \"ack\",\n  \"payload\": { \"sequence\": 42, \"timestamp\": 1716130000000, \"streamSeq\": 12, \"session\": null }\n}\n```\n\n| Field             | Description                                                                                                               |\n| ----------------- | ------------------------------------------------------------------------------------------------------------------------- |\n| `sequence`        | Inbound event-ledger position.                                                                                            |\n| `timestamp`       | Epoch milliseconds.                                                                                                       |\n| `session`         | Current `GguiSession` snapshot when one is already committed; `null` / absent otherwise.                                  |\n| `streamSeq`       | The outbound cursor at the ack: the live tail begins after it. Not a dedupe floor (see below).                            |\n| `streamEpoch`     | The generation of the counter `streamSeq` counts in. A different one from the epoch you hold means the counter restarted. |\n| `replayTruncated` | `true` when a requested `fromSeq` predates the server's buffer window — the client got the live tail but missed history.  |\n| `sessionToken`    | Deprecated: sent by no server since this release, and removed in the next. Don't depend on it.                            |\n| `serverVersion`   | Server's `PROTOCOL_SCHEMA_VERSION` stamp — see the protocol-version handshake below.                                      |\n\n### `render`\n\nThe agent committed a new GguiSession for this `sessionId`. The payload is `{ session: GguiSession, matchType? }` — the frame discriminator `type: \"render\"` stays verb-named; the object inside is the `GguiSession`.\n\n```json\n{\n  \"type\": \"render\",\n  \"payload\": {\n    \"session\": {\n      \"id\": \"ses_xyz\",\n      \"componentCode\": \"/* compiled JS */\",\n      \"propsSpec\": {/* … */},\n      \"actionSpec\": {/* … */},\n      \"streamSpec\": {/* … */},\n      \"contextSpec\": {/* … */}\n    }\n  }\n}\n```\n\n### `props_update`\n\nThe `ggui_update` / `ggui_amend` fan-out — the agent mutated props on this GguiSession. `props` is the FULL replacement state (post-merge for `kind: \"merge\"` updates), not a patch. Persistence is the source of truth; this frame is the latency optimization. `epoch` is the commit-time epoch — `ggui_update` fans its just-advanced epoch, `ggui_amend` fans the unchanged head epoch. It is also the freeze-latch signal: a mount whose own epoch is lower than the frame's `epoch` freezes (superseded).\n\n`epoch` is OPTIONAL on the wire. Absent reads as `0` — pre-epoch producers and amends against an epoch-0 head omit it. Consumers MUST tolerate the absence rather than reject the frame; a client that treats `epoch` as required will throw on a legitimate amend.\n\n```json\n{\n  \"type\": \"props_update\",\n  \"payload\": { \"sessionId\": \"ses_abc123\", \"props\": { \"rating\": 5 }, \"epoch\": 3 }\n}\n```\n\n### `render_event`\n\nOne `GguiSessionEvent` from the per-session event ledger, replayed when `subscribe.payload.sinceSequence` is set. Same ledger as `GET /api/sessions/:sessionId/events` — two transports, one cursor.\n\nThe frame body is `{ seq, type, timestamp, data }`. `seq` is monotonic and gap-free per session, starting at 1 (`0` is the \"no events yet\" sentinel). `type` discriminates; `data` is structurally identical to the matching live-channel frame's payload.\n\n#### Event-type taxonomy\n\n`GguiSessionEventType` is the canonical taxonomy. Implementations MUST emit events for the core types; custom types MAY be added, and MUST carry an `x-` or `ext:` prefix so they can never collide with a future core name. Consumers MUST tolerate a type they don't recognize.\n\n| Type             | Meaning                                                                       | `data`                                        |\n| ---------------- | ----------------------------------------------------------------------------- | --------------------------------------------- |\n| `ui.created`     | A render was minted for this session.                                         | Implementation-defined.                       |\n| `ui.committed`   | The render's component + contract were committed and are mountable.           | Implementation-defined.                       |\n| `ui.updated`     | Props changed — the ledger twin of the `props_update` frame.                  | `{ sessionId, props, epoch? }`                |\n| `ui.reminted`    | The session head advanced to a new epoch-numbered record. `ggui_update` only. | `{ epoch }` — the epoch the head advanced TO. |\n| `tool.called`    | A tool call was issued against this session.                                  | Implementation-defined.                       |\n| `tool.result`    | A tool call returned.                                                         | Implementation-defined.                       |\n| `user.submitted` | The user fired a gesture — the retained `ActionEnvelope`.                     | The `ActionEnvelope`.                         |\n\nStated honestly: the reference `ggui serve` appends `ui.updated`, `ui.reminted`, and `user.submitted` to the replayable ledger today. `ui.created` and `ui.committed` are the names `ggui_render` emits on its telemetry sink; `tool.called` / `tool.result` are reserved taxonomy names. A conformant implementation may write more than the reference does, so consume defensively and never key behavior on a type's absence.\n\n#### Epochs are ledger-derived\n\nThere is no epoch counter field on the session row. **The count of `ui.reminted` events at a `seq` IS the epoch at that `seq`.** A replaying client folds the ledger forward and increments its epoch on every `ui.reminted`; slicing at a cursor yields the epoch as of that cursor, which is exactly how a pinned `#N` read reconstructs its snapshot (props replayed up to and including the N-th remint boundary).\n\nThat derivation is what makes the freeze latch work: a `ui.reminted` whose `epoch` exceeds the epoch in the mount's own resource URI means this mount has been superseded, and it freezes rather than applying newer state. The [`props_update`](#props_update) frame carries the same signal on the live path.\n\n### `data`\n\nAn outbound [`StreamEnvelope`](/protocol/envelopes/#streamenvelope). `channel` names the `streamSpec[name]` it belongs to; `mode` is `append` or `replace`.\n\n```json\n{\n  \"type\": \"data\",\n  \"payload\": {\n    \"sessionId\": \"ses_abc123\",\n    \"channel\": \"message\",\n    \"mode\": \"append\",\n    \"payload\": { \"text\": \"Found 3 flights.\", \"sender\": \"agent\" },\n    \"seq\": 7\n  }\n}\n```\n\n### `error`\n\nA server-side error — transport, auth, subscribe rejection, or an inbound action that failed contract validation. An inbound action that fails contract validation surfaces here as a typed `error` frame with code `CONTRACT_VIOLATION`; nothing reaches the consume buffer. The earlier `_ggui:contract-error` reserved channel, its `ContractErrorPayload`, and the `ContractErrorCode` union were deleted in draft-2026-06-11 — contract failures now surface on the call that caused them, not on a side channel.\n\nCanonical `code` values exported from `@ggui-ai/protocol`:\n\n| Code                 | Meaning                                                                                                                                                                                                                                                                                  |\n| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `UPGRADE_REQUIRED`   | Protocol-version handshake mismatch (see below). Servers default to `versionPolicy: 'reject'` and close the connection after emitting this frame.                                                                                                                                        |\n| `CONTRACT_VIOLATION` | An inbound action failed validation against the render's `actionSpec` (undeclared name or schema-rejected payload); nothing reaches the consume buffer. A dispatch through the relay (`ggui_runtime_submit_action`) gets the same code as an `ok: false` tool result instead of a frame. |\n\nOther codes are free-form strings. Server-emitted today: `SESSION_MISMATCH`, `BOOTSTRAP_SESSION_MISMATCH`, `SESSION_CREATE_FAILED`, `REPLAY_HORIZON_PASSED`; the `channel_error` frame uses `CHANNEL_UNKNOWN`, `CHANNEL_NOT_LOCAL`, `SESSION_NOT_FOUND`, `SUBSCRIBE_UNAUTHORIZED`, `POLL_FAILED`. See [Envelopes](/protocol/envelopes/).\n\n---\n\n## Protocol-version handshake\n\nBoth peers advertise their schema version on the wire so version mismatch surfaces explicitly instead of silently corrupting state.\n\n- `subscribe.payload.supportedVersions?: string[]` — client declares the versions it accepts (first-party clients populate from `CLIENT_SUPPORTED_VERSIONS`).\n- `ack.payload.serverVersion?: string` — server stamps its `PROTOCOL_SCHEMA_VERSION` on every ack.\n\nMismatch policy:\n\n- **Server-side** — if `subscribe.supportedVersions` is present and the server's `PROTOCOL_SCHEMA_VERSION` isn't a member, the server replies with `error { code: 'UPGRADE_REQUIRED' }`. Default `versionPolicy: 'reject'` also closes the socket; `versionPolicy: 'advisory'` keeps it open (controlled-migration opt-out only).\n- **Client-side** — if `ack.serverVersion` is absent from the client's `CLIENT_SUPPORTED_VERSIONS`, the client surfaces `UPGRADE_REQUIRED` on its error channel.\n\nAbsent declarations on either side are legacy-pass-through (version-agnostic) — preserves pre-handshake behavior for older peers.\n\n---\n\n## Lifecycle\n\n```\n1. Connect to ws[s]://<host>/ws?wsToken=<token>\n2. Send \"subscribe\" (sessionId + appId + wsToken, optional fromSeq / sinceSequence)\n3. Receive \"ack\" (carries streamSeq + initial session)\n4. Loop: receive \"render\" / \"props_update\" / \"data\" / \"error\"\n         send  \"action\"\n   (generation-pipeline progress arrives as a {type:'data'} delivery on the reserved _ggui:lifecycle channel)\n5. Close on session end or client disconnect\n```\n\n### A fresh subscribe and `streamSeq`\n\nA subscribe without `fromSeq` replays no channel the render declares: the ack carries the initial state. The server may still send the retained envelopes of the reserved `_ggui:*` channels, such as the `_ggui:preview` skeleton, right after the ack, with `seq` at or below the ack's `streamSeq`. Apply them. Dedupe against the highest `seq` you have applied, never against `streamSeq`, which only marks where the live tail begins (SPEC §12.2.1).\n\n### Reconnection\n\n`@ggui-ai/live-channel`'s `WSTransport` — reached through `@ggui-ai/iframe-runtime` inside the render iframe — reconnects automatically with exponential backoff (1s → 2s → 4s → … capped at 60s, max 10 attempts). One exception shortens the ladder: on close code `1012` (service restart) the first retry skips backoff, so a server roll doesn't show the user a multi-second blink. A subscribe the server acknowledged but could not complete, such as a failed event-ledger read, closes with `1011`, which takes the ordinary backoff.\n\nIf the socket never opens on two consecutive attempts, the ladder bails immediately instead of burning the full schedule. That is the signature of a structurally unreachable destination — a host CSP or iframe sandbox that blocks `wss://` — not a transient drop, and the runtime pivots to its polling transport instead.\n\nTo resume the outbound stream without gaps, track the last observed `StreamEnvelope.seq` and its `streamEpoch`, and pass them as `fromSeq` and `fromEpoch` on the next `subscribe`. The server replays buffered envelopes (where supported) before re-entering the live tail.\n\n### When the counter restarts\n\nA `seq` counts within one counter, and a counter can restart: after a long idle period, or when a server restarts over a persistent session store. Each counter carries a generation, `streamEpoch`, on every envelope with a `seq` and on the ack. If you dedupe by `seq`, keep the epoch beside your highest applied `seq`. An envelope or ack of a different epoch means the counter restarted, so reset to nothing applied and apply it, whatever its `seq`. That covers a restart while the socket stays open, which no ack announces. A resume whose `fromEpoch` is not the session's current one is replayed in full, with `replayTruncated: true`. A server or envelope without an epoch is unknown, never a mismatch (SPEC §12.2.1, invariant 4).\n\n### Status (transport)\n\n| Status         | Meaning                            |\n| -------------- | ---------------------------------- |\n| `connecting`   | Initial socket handshake in flight |\n| `connected`    | Subscribed; `ack` received         |\n| `disconnected` | Socket closed, no retry pending    |\n| `reconnecting` | Backoff in progress after a drop   |"},{"path":"/architecture/agent-backend.md","title":"The agent backend","description":"How a ggui-aware agent is hosted — @ggui-ai/agent-server (a brand-agnostic Hono backend) plus a thin per-SDK AgentAdapter. The three channels, the user-action doorbell, and what \"Zero Agent Code\" means.","body":"---\ntitle: The agent backend\ndescription: How a ggui-aware agent is hosted — @ggui-ai/agent-server (a brand-agnostic Hono backend) plus a thin per-SDK AgentAdapter. The three channels, the user-action doorbell, and what \"Zero Agent Code\" means.\n---\n\nA ggui-aware agent needs an HTTP backend the user's chat client can talk to. You do **not** hand-roll that backend. The OSS reference implementation is **[`@ggui-ai/agent-server`](https://www.npmjs.com/package/@ggui-ai/agent-server)** — a brand-agnostic [Hono](https://hono.dev) server that owns every ggui-coupled host concern — plus a thin **`AgentAdapter`** that maps your LLM SDK's event stream to a normalized message envelope.\n\nThe split is the point: the agent-server has **zero LLM-SDK knowledge** and the adapter has **zero GGUI awareness**. The protocol lives entirely between the two.\n\n## Three parties\n\n```\n        ┌─────────────────────────┐\n        │   host / chat client    │   MCP-Apps host: claude.ai, ChatGPT,\n        │  (owns the chat UI,     │   or the sample chat app. Forwards\n        │   forwards ui/message)  │   ui/message text to the model.\n        └───────────┬─────────────┘\n              chat   │  (HTTP POST /agent → SSE)\n                     ▼\n        ┌─────────────────────────┐\n        │     agent backend       │   @ggui-ai/agent-server (Hono)\n        │  agent-server + a thin  │   + your AgentAdapter (per-SDK glue).\n        │     AgentAdapter        │\n        └───────────┬─────────────┘\n              MCP    │  (Streamable HTTP)\n                     ▼\n        ┌─────────────────────────┐\n        │     ggui MCP server     │   ggui_handshake / ggui_render /\n        │  (GguiSessions + state  │   ggui_update / ggui_amend / ggui_consume / ggui_emit,\n        │   + the iframe runtime) │   plus the iframe runtime served per session.\n        └─────────────────────────┘\n```\n\nThe **iframe** (running [`@ggui-ai/iframe-runtime`](https://www.npmjs.com/package/@ggui-ai/iframe-runtime)) is the rendered surface that lives inside the host. It is not a fourth party — it is the GGUI MCP server's UI, mounted in the host.\n\n## Three channels\n\n| Channel  | Between                         | Transport                | Status    | Carries                                                                                              |\n| -------- | ------------------------------- | ------------------------ | --------- | ---------------------------------------------------------------------------------------------------- |\n| **chat** | host ↔ agent backend            | HTTP `POST /agent` → SSE | Mandatory | `{kind: 'chat', prompt, chatId?}` in; a stream of `NormalizedMessage`s out                           |\n| **MCP**  | agent backend ↔ GGUI MCP server | MCP Streamable HTTP      | Mandatory | `ggui_handshake` / `ggui_render` / `ggui_update` / `ggui_amend` / `ggui_consume` / `ggui_emit` calls |\n| **live** | GGUI MCP server ↔ iframe        | WebSocket (+ fallback)   | Optional  | declared `streamSpec` deliveries (`ggui_emit` fan-out) and `props_update` frames                     |\n\n**chat** is how a turn starts. `POST /agent` is one kind-discriminated endpoint: `{kind: 'chat', prompt, chatId?}` opens the SSE stream — the first event is always `chat-allocated`, carrying the server-allocated `chatId`; subsequent frames are `message` events. The same endpoint also accepts `{kind: 'tool-call', name, arguments}` — the iframe-issued `tools/call` relay, answered as plain JSON rather than SSE. `GET /agent?chatId=X` replays the server-authoritative snapshot through the same handler for rehydration (each recorded tool result is re-inlined fresh so the snapshot reflects current server state), and `GET /` serves a small public manifest the frontend reads `sandboxProxyUrl` from.\n\n**MCP** is the agent loop — the adapter's LLM calls `ggui_render` / `ggui_consume` / etc. against the GGUI MCP server using the URL + bearer the library threads on every call.\n\n**live** is the first-party fast path for streaming UI updates into the iframe over WebSocket (gated by a `wsToken`). The spec-compliant cross-host fallback is **tool-result inlining**: agent-server's interceptor reads `_meta.ui.resourceUri` on each tool result, issues a `resources/read`, and inlines the iframe HTML under `_meta.ui.resource` — so the iframe mounts on the first SSE frame with no extra round-trip.\n\n## What agent-server owns\n\nThe library owns **every ggui-coupled host concern**, and nothing else:\n\n- HTTP routing (Hono) + SSE streaming\n- MCP discovery / routing + bearer threading (`bearer` defaults to `GGUI_MCP_BEARER`, then `dev` — pairing with `ggui serve --dev-allow-all`)\n- **Tool-result resource inlining** (`interceptToolResult`) — mounting iframes from `_meta.ui.resourceUri`\n- Server-allocated chat ids (`mintChatId`) — the frontend never mints ids client-side\n- Guest + bearer **auth** with chat-ownership gating\n- The second-origin **sandbox proxy** boot (per the MCP Apps spec; defaults to `port + 1000`)\n- The snapshot / rehydration path\n- **Cross-framework tool identity**: with `crossFramework` on (the `startAgentServer` default), the library declares each tool's canonical `serverInfo` to GGUI once per process via `ggui_runtime_declare_tool_catalog`, so blueprint reuse stays identity-stable across agent frameworks\n\nCrucially, it is a **pure prompt forwarder**: the prompt is fed to the adapter verbatim, and the server synthesizes no directive and special-cases no key. The user-gesture directive that tells the model to call `ggui_consume` is authored in the iframe's `ui/message` text and passes straight through (see [the user-action flow](#the-user-action-flow) below).\n\n## What the AgentAdapter implements\n\nA thin per-SDK adapter implements **one** async-iterable method:\n\n```ts\nimport { startAgentServer, type AgentAdapter } from \"@ggui-ai/agent-server\";\n\nconst adapter: AgentAdapter = {\n  name: \"my-sdk\",\n  async *run(input) {\n    // input.prompt        — the string the LLM should see (verbatim)\n    // input.chatId        — server-allocated stable id for this conversation\n    // input.mcpServers    — { name → { url, bearer } } map (e.g. { ggui: {…} })\n    // input.systemPrompt  — three-way: undefined = adapter default,\n    //                       null = explicitly none, string = override\n    // input.abortSignal   — fires on client disconnect; stop the LLM call\n    // input.agentCapabilities — canonical tool catalog (from live MCP\n    //                       initialize + tools/list) to stamp into the\n    //                       handshake's blueprintDraft contract\n    //\n    // Drive your SDK's tool loop and yield NormalizedMessage values:\n    //   assistant text · tool_use · tool_result (carrying the full MCP\n    //   CallToolResult as `tool_use_result`) · result\n  },\n};\n\nawait startAgentServer({\n  port: 6790,\n  mcpServers: { ggui: { url: \"http://localhost:6781/mcp\" } }, // a `ggui` entry is required\n  adapter,\n  // optional: auth (default createGuestTokenAuth()), sandboxProxyPort\n  // (default port + 1000), systemPrompt, bearer, chatStore, crossFramework\n});\n```\n\nAdapters **must stay brand-agnostic**: no imports of `@ggui-ai/protocol/integrations/mcp-apps`, no awareness of `sessionId` / `host-session` / `_meta.ui` keys. The adapter maps its native SDK event stream onto the `NormalizedMessage` envelope; GGUI mechanics stay in agent-server. Reference adapters ship for the Claude Agent SDK (`claude-agent-sdk`), the OpenAI Agents SDK (`openai-agents-sdk`), and Google ADK (`google-adk`).\n\n## Frontend pairing — `@ggui-ai/mcp-apps-react/chat-helpers`\n\nOn the browser side, agent-server pairs with the **`useMcpAppsChat`** hook from [`@ggui-ai/mcp-apps-react/chat-helpers`](/host-helpers/react/). It:\n\n- opens the SSE stream to `POST /agent` and parses `chat-allocated` then `message` frames into one wire;\n- walks each tool result's `tool_use_result` for `_meta.ui.resourceUri` (and any inlined `_meta.ui.resource`) and surfaces the result as `sessions` entries your app mounts with `<AppRenderer>` (imported directly from `@mcp-ui/client` — GGUI doesn't wrap or re-export it);\n- replays `GET /agent?chatId` through the same pipeline for rehydration;\n- runs the guest-token client flow (`POST /auth/guest` → store → `Bearer` on every request → retry once on `401`);\n- forwards an iframe `ui/message`'s text as the next prompt via `handleAppMessage` and carries its `_meta` **opaquely** as `data.meta` — it never reads a key inside.\n\n## The user-action flow\n\nWhen a user interacts with a rendered UI, the gesture travels back to the agent through the **GguiSession's pending-event pipe**, which is the single source of truth:\n\n1. **Gesture in the iframe** → the iframe runtime calls `ggui_runtime_submit_action` via the host's `tools/call` relay (postMessage per the MCP Apps spec; in the sample stack, the host relays it as `POST /agent {kind: 'tool-call'}`).\n2. The GGUI server **appends the gesture to the GguiSession's pending-event pipe** and returns `{ok, consumerPresent}`.\n3. `consumerPresent` is computed by an active-consumer registry: `ggui_consume` registers itself while long-polling, so `submit_action` knows whether a consume loop is currently listening.\n4. **If a `ggui_consume` long-poll is listening**, it unblocks in-turn and returns the event `{intent, actionData, uiContext, actionId, firedAt}` to the agent.\n5. **If nobody is listening** (`consumerPresent: false` — e.g. the user reloaded the page after the agent's turn ended), the iframe emits a **userAction doorbell** on a `ui/message`. The host forwards the message text to the model, which wakes a fresh turn and calls `ggui_consume({sessionId})` to drain the already-enqueued gesture.\n\nThe agent retrieves the gesture **exclusively** via `ggui_consume`, so it fires **exactly once**. The doorbell is a **pure pointer** — `_meta[\"ai.ggui/userAction\"]` (`GguiUserActionMeta`) carries only `{kind: 'user-action', description, sessionId, actionId, submittedAt, intent, nextStep: {tool: 'ggui_consume', args: {sessionId}}}`, never the action payload. Carrying the payload in the doorbell would risk a double-trigger.\n\n:::note[The directive lives in the iframe's text]\nThe actionable \"call `ggui_consume` now\" directive is authored entirely by the iframe runtime as the human-readable `ui/message` **text** (XML-tagged, imperative-first — load-bearing wording every host forwards verbatim). The `_meta[\"ai.ggui/userAction\"]` slice is an **optional structured mirror** for ggui-aware programmatic consumers. No part of the loop depends on a server-side parse of it — which is exactly why agent-server can stay a pure forwarder.\n:::\n\n## \"Zero Agent Code\", redefined\n\n[Zero Agent Code](/protocol/overview/) now means an agent builder writes only:\n\n1. **MCP server config** naming the GGUI MCP endpoint,\n2. a **system prompt** (start from `GGUI_AGENT_SYSTEM_PROMPT`, exported by `@ggui-ai/protocol`), and\n3. a few lines wiring a thin **`AgentAdapter`** into `startAgentServer()`.\n\nNo polling loops, no event handlers, no protocol parsing, no `sessionId` / `host-session` awareness. All of that lives inside `@ggui-ai/agent-server`. The adapter is intentionally brand-agnostic SDK-mapping glue — not GGUI logic.\n\n## Auth posture (Preview)\n\nagent-server ships two `AuthAdapter` implementations:\n\n- **`createGuestTokenAuth` (default)** — stateless signed bearer tokens (signing secret from `GUEST_TOKEN_SECRET`, ephemeral with a warning if omitted) that work across browser / React Native / CLI. Mounts `POST /auth/guest`, `GET /auth/me`, `POST /auth/logout`.\n- **`createBearerTokenAuth`** — static operator-configured tokens for sample apps, CI, and small self-hosts. Mounts `GET /auth/me` only.\n\nEvery chat row is stamped with an `ownerId`; reads and appends are ownership-gated (`200` owner / `403` other / `404` unknown), overridable via `authorizeChat` for team / org semantics. Richer JWT / JWKS / OAuth + PKCE flows are deferred to a future `@ggui-ai/agent-server-auth-extras` (same `AuthAdapter` contract, no handler rewrites). For the Preview, the bundled guest-token + static-bearer paths are the supported surface.\n\n## See also\n\n- [How GGUI works](/how-it-works/) — the handshake → render → interact → consume loop\n- [Architecture overview](/architecture/overview/) — the wire pipeline at a glance\n- [Event System](/architecture/event-system/) — the pending-event pipe + the consume model\n- [React host helpers](/host-helpers/react/) — `useMcpAppsChat` and `<AppRenderer>` on the frontend\n- [MCP Protocol reference](/api/mcp-protocol/) — `ggui_render` / `ggui_consume` / `ggui_update` / `ggui_amend` / `ggui_emit`"},{"path":"/architecture/audience-routes.md","title":"Audience routes","description":"Every MCP tool carries an audience tag that determines which surface it appears on — the /mcp data plane or the /control control plane. This page explains the four audiences and the routing rules.","body":"---\ntitle: Audience routes\ndescription: Every MCP tool carries an audience tag that determines which surface it appears on — the /mcp data plane or the /control control plane. This page explains the four audiences and the routing rules.\n---\n\nA single GGUI server exposes two distinct MCP surfaces, not one. Each tool the server registers carries an **audience tag** that decides which surface the tool appears on. The agent runtime sees one slice of the tool set; design-time clients and operators see the other. The audience tag is the structural mechanism that keeps those slices honest.\n\nThis page explains the four audiences, the two surfaces they map onto, and the placement rules for every new handler.\n\n## Why audiences exist\n\nDifferent callers connect to a GGUI server for different reasons:\n\n- An **LLM agent** in the middle of a chat turn needs `ggui_render`, `ggui_handshake`, blueprint search, and not much else.\n- The **view runtime** — the iframe-runtime, relayed through the host's `tools/call` — and first-party backend libraries (e.g. `@ggui-ai/agent-server`) need callbacks like `ggui_runtime_sync_context` and `ggui_runtime_submit_action`.\n- A **design-time client** authoring blueprints needs static spec/discovery tools like `ggui_protocol_describe_blueprint_format` once, then never again.\n- An **operator** managing apps, keys, and orgs needs administrative tools that should never appear in an LLM's `tools/list`.\n\nPutting all of those tools on one `/mcp` surface burns agent context on tools the agent will never call, and exposes operator surfaces to runtimes that shouldn't see them. The audience tag splits the surface so each caller's `tools/list` is exactly the tools that caller cares about.\n\n:::note\nThe audience tag is the structural source of truth. The wire-name prefix (`ggui_*`, `ggui_runtime_*`, `ggui_protocol_*`, `ggui_ops_*`) is a parallel signal for agent UX — see [Wire-name prefix discipline](#wire-name-prefix-discipline) below.\n:::\n\n## The four audiences\n\n| Audience   | Surfaces on | Wire-name prefix  | Who calls                                                                                             |\n| ---------- | ----------- | ----------------- | ----------------------------------------------------------------------------------------------------- |\n| `agent`    | `/mcp`      | `ggui_*`          | The LLM agent itself during a chat turn (render, handshake, blueprint search)                         |\n| `runtime`  | `/mcp`      | `ggui_runtime_*`  | The view runtime — iframe-runtime (via the host's `tools/call` relay) + first-party backend libraries |\n| `protocol` | `/control`  | `ggui_protocol_*` | Design-time spec/discovery clients (conformance suites, registry browsers)                            |\n| `ops`      | `/control`  | `ggui_ops_*`      | Operator agents — an LLM acting as a console operator, dashboards, CI                                 |\n\nEach tag answers exactly one question: **who is calling this tool, and on what time-scale?**\n\n### `agent`\n\nSurfaced on `/mcp`. The LLM agent calls these tools live, inside a chat turn. They typically mutate render state, render contracts, or look up blueprints by intent.\n\nWire-name prefix: bare `ggui_*` (e.g. `ggui_render`, `ggui_handshake`, `ggui_update`). The bare prefix is reserved for the canonical agent route — these are the tools an agent calls most often, and they don't need a route disambiguator.\n\n### `runtime`\n\nSurfaced on `/mcp` alongside `agent` tools. Called by the view runtime — the iframe-runtime, relayed through the host's MCP-Apps `tools/call` — and by first-party backend libraries (e.g. `@ggui-ai/agent-server` declaring the per-app tool catalog via `ggui_runtime_declare_tool_catalog`) — not by the LLM directly. They handle things like syncing renderer state back to the server or submitting user actions.\n\nWire-name prefix: `ggui_runtime_*` (e.g. `ggui_runtime_sync_context`, `ggui_runtime_submit_action`).\n\n:::note\n`/mcp` is a **union** of `agent` and `runtime` handlers. Both audiences live on the same HTTP route, and the MCP-Apps visibility marker layered on top (`_meta.ui.visibility: [\"app\"]` on every `runtime` tool) asks the host to keep them from the model while iframe code invokes them through the same MCP connection. The server lists them in `tools/list` and does not enforce the marker; withholding them is the host's job (SPEC §4.7, \"Host obligation\").\n:::\n\n### `protocol`\n\nSurfaced on `/control`, and answered **anonymously** — an agent authoring a blueprint has no account yet, so these tools must work with no bearer token. Static design-time tools that describe the protocol itself: example blueprints, format references, schema validators. A client calls these **once** while authoring against the protocol, not during runtime.\n\nWire-name prefix: `ggui_protocol_*` (e.g. `ggui_protocol_describe_blueprint_format`, `ggui_protocol_validate_blueprint`, `ggui_protocol_get_example_blueprints`).\n\nThe litmus test: would the result change if the same caller invoked the tool again five minutes later? If no — the tool returns a static format reference or immutable example set — it belongs on `/control` as a `protocol` tool. If yes, it's a runtime lookup and belongs on `/mcp`.\n\n### `ops`\n\nSurfaced on `/control` alongside `protocol` tools, but **auth-gated per handler**, and **confirm-gated** when state-changing. Operator-facing tools an LLM operator (or dashboard, or CI script) uses to manage apps, register blueprints, issue connector keys, redeem coupons, list orgs. Never visible to the agent runtime, never invoked from inside a rendered UI.\n\nWire-name prefix: `ggui_ops_*` (e.g. `ggui_ops_create_app`, `ggui_ops_list_orgs`, `ggui_ops_issue_connector_key`).\n\n## Surfaces table\n\nThe four audiences map onto two surfaces:\n\n| Surface           | Route                                      | Audiences mounted   | Typical caller                        | Auth posture                                                |\n| ----------------- | ------------------------------------------ | ------------------- | ------------------------------------- | ----------------------------------------------------------- |\n| **Data plane**    | `/mcp` (+ `/apps/<appId>` when configured) | `agent` ∪ `runtime` | LLM agent + view runtime              | Bearer token or session auth                                |\n| **Control plane** | `/control`                                 | `protocol` ∪ `ops`  | Design-time clients + operator agents | Anonymous for `protocol`; bearer required per `ops` handler |\n\nThe projection reads each handler's `audience` array and includes it on the surface whose audience set intersects the handler's tags. A handler tagged `audience: ['agent']` lands on the data plane. A handler tagged `audience: ['agent', 'runtime']` also lands on the data plane (the union doesn't change membership). A handler tagged `audience: ['ops']` lands on the control plane.\n\nWhen per-app routing is configured, the same `agent` ∪ `runtime` surface also mounts at a per-app path (`/apps/<appId>` on hosted deployments); the audience model is identical — only the tenancy resolution differs.\n\n### One route, two auth postures\n\n`/control` is mounted **anonymous-capable**: a request with no bearer token is admitted with a synthesized anonymous identity, so the `protocol` half answers. Every `ops` handler then re-imposes auth for itself and refuses an anonymous caller with an auth error the client can act on.\n\nState-changing `ops` tools add a second step. The first call returns a confirmation preview and mutates nothing; only a second call carrying `confirm: true` commits:\n\n```jsonc\n// 1st call → preview, nothing happens\n{ \"name\": \"ggui_ops_delete_app\", \"arguments\": { \"appId\": \"aB3kP9xY\" } }\n// → { \"confirmationRequired\": true, \"confirmationPrompt\": \"…\" }\n\n// 2nd call → commits\n{ \"name\": \"ggui_ops_delete_app\", \"arguments\": { \"appId\": \"aB3kP9xY\", \"confirm\": true } }\n```\n\nThe gate exists so an agent cannot silently mint credentials, spend credits, or delete resources on a user's behalf. It is default-deny: an `ops` tool not explicitly classified as read-only is treated as state-changing.\n\n:::note[Why one route and not two]\n`protocol` and `ops` had their own routes until 2026-07-28. An HTTP route carries exactly **one** auth posture, and these two need opposite ones — design-time tools must answer bearer-less, operator tools must never. With a route per audience, no single client configuration could reach both. Merging them onto one anonymous-capable route with per-handler gates is what makes a single connection serve both.\n\nThe audience **tags** did not change. Only the mounting did.\n:::\n\n:::caution\nA handler with **no** audience tag is mounted on `/mcp` by default. This is a backward-compatibility behavior — handlers added before audience tagging existed default to `agent`. New handlers should always declare an explicit `audience` array.\n:::\n\n## Placement decision tree\n\nWhen you add a new MCP handler, walk this tree before picking an audience:\n\n1. **Does the LLM agent invoke this during a chat turn?**\n   Yes → `audience: ['agent']`.\n2. **Is this a tool declared with `_meta.ui.visibility: ['app']`** that the rendered view invokes through the host's MCP-Apps `tools/call` relay?\n   Yes → `audience: ['runtime']`.\n3. **Is this a static spec or discovery tool a client reads once while authoring against the protocol?**\n   Yes → `audience: ['protocol']`.\n4. **Is this an administrative operation a human, dashboard, CI, or operator agent performs out-of-band?**\n   Yes → `audience: ['ops']`.\n\nThe placement test is exclusive — if more than one branch fires, pick the **most-frequent caller** and tag that audience. Multi-audience tags are rare; see below.\n\n### The \"is this a runtime lookup?\" trap\n\nTools like `ggui_search_blueprints` _sound_ like spec/discovery — they discover blueprints. But their results change per-app and per-session: the agent calls them at chat time to decide what to build, not to learn the protocol's format. They are runtime lookups, tagged `agent`, surfaced on `/mcp`.\n\nThe litmus test repeats: **would the result change if the same caller invoked this tool again five minutes later?**\n\n- Yes → runtime lookup → `agent` (or `runtime` if called from the iframe).\n- No → static spec/discovery → `protocol`.\n\n## Wire-name prefix discipline\n\nThe prefix encodes the audience at the wire-name level so a tool reader can infer what a tool is for without consulting documentation. An LLM scanning `tools/list` on `/control` sees `ggui_protocol_describe_blueprint_format` and knows it is a free design-time read, while `ggui_ops_delete_app` announces itself as an operator mutation that will want confirmation. Since both halves share one `tools/list`, the prefix is what tells them apart.\n\n| Prefix            | Implied surface | Implied audience |\n| ----------------- | --------------- | ---------------- |\n| `ggui_*` (bare)   | `/mcp`          | `agent`          |\n| `ggui_runtime_*`  | `/mcp`          | `runtime`        |\n| `ggui_protocol_*` | `/control`      | `protocol`       |\n| `ggui_ops_*`      | `/control`      | `ops`            |\n\nBare `ggui_*` is the exception, not the rule. It is reserved for agent runtime essentials — the canonical chat-turn tools that don't need a route disambiguator. Every other audience requires the explicit prefix.\n\n:::caution\nThe prefix and the `audience` tag must agree. A handler named `ggui_protocol_foo` with `audience: ['ops']` is a discipline violation — the name promises a free anonymous read, while the tag imposes auth and a confirmation gate. Both land on `/control`, so nothing breaks at boot; the caller just gets behavior its name disclaimed. Keep them in sync.\n:::\n\n## How a handler declares its audience\n\nThe `audience` field lives on every `SharedHandler`. It is a `ReadonlyArray<'agent' | 'runtime' | 'protocol' | 'ops'>` — an array because multi-audience tagging is structurally permitted (see below).\n\n```ts\nimport type { SharedHandler } from '@ggui-ai/mcp-server-handlers';\n\nexport function createListOrgsHandler(): SharedHandler<…> {\n  return {\n    name: 'ggui_ops_list_orgs',\n    title: 'List organizations',\n    audience: ['ops'],\n    description: 'Enumerate orgs visible to the calling operator.',\n    inputSchema: { /* … */ },\n    outputSchema: { /* … */ },\n    async handler(input, ctx) {\n      /* … */\n    },\n  };\n}\n```\n\nThat's the entire contract. Once a handler is registered through the normal channel (the `handlers` array passed to `createGguiServer`), the surface projection reads the `audience` field at compose time and decides which surface the handler appears on. There is no separate route-registration step.\n\nOne extra obligation for `ops` handlers: declare a **non-empty `outputSchema`**. The control plane validates this at construction time, because an empty schema silently strips `structuredContent` at the MCP SDK boundary.\n\n## Multi-audience handlers\n\nThe `audience` field is an array, not a scalar, because a handler can legally surface on more than one caller class. In practice this is rare and intentional:\n\n```ts\nexport function createSomeBoundaryToolHandler(): SharedHandler<…> {\n  return {\n    name: 'ggui_some_boundary_tool',\n    audience: ['agent', 'runtime'],\n    // …\n  };\n}\n```\n\nSuch a handler appears in both the agent's `tools/list` and the iframe runtime's view. The canonical example is a tool that has to land on the agent's wire AND accept calls from inside the rendered UI — the runtime essentials that genuinely span both callers.\n\nMulti-audience is a tool that serves multiple caller classes simultaneously. If you find yourself reaching for it, double-check the placement test first — most \"multi-audience\" tools are actually two tools wearing a trench coat, and splitting them sharpens both surfaces.\n\n`['protocol', 'ops']` is the one combination that is always a bug: both project onto `/control`, so the tool would be registered twice and the server would refuse to start on the duplicate name.\n\n## Relation to MCP services\n\nThe audience model governs the two **shared** surfaces — `/mcp` and `/control` — where handlers from different sources are aggregated under audience filtering. A separate concept, **MCP services**, lets a server expose isolated, complete MCP servers at their own HTTP paths (e.g. `https://your-server/docs`, `https://your-server/playground/todos`).\n\n| Concept      | Routing mechanism                  | Tool isolation        | When to use                                 |\n| ------------ | ---------------------------------- | --------------------- | ------------------------------------------- |\n| **Audience** | Tag filters tool onto shared route | Tools share namespace | Tool belongs alongside ggui-native tools    |\n| **Service**  | Path mounts an isolated MCP server | Per-path namespace    | Tool set is conceptually its own MCP server |\n\nA service handler **must not** set an `audience` tag — the path IS the audience. The compose-time validator rejects services with audience-tagged handlers loudly: services bypass audience filtering entirely, so a tag would be silently meaningless.\n\n→ See [MCP services](/architecture/mcp-services/) for the full service model.\n\n## Placement anti-patterns\n\nAudience tagging makes it cheap to add new tools, which means the _placement_ discipline matters more than ever. Some patterns to avoid:\n\n- **`ops`-audience tools that mutate non-tenant data.** The control plane is operator-bounded but still scoped to the calling operator's tenant. A tool that lets one operator probe another tenant's apps is a confused-deputy bug, not a feature.\n- **Cross-tenant probing.** `ggui_ops_list_orgs` should enumerate orgs the caller can see, not all orgs. If a tool needs admin-level visibility, gate it on an explicit role check at the handler boundary, not at the route boundary.\n- **`protocol`-audience tools that return runtime data.** If a result depends on which session is calling, it is a runtime lookup. Move it to `agent`.\n- **`runtime`-audience tools that the agent should also call.** If an LLM agent needs the data, tag it `agent` (or `['agent', 'runtime']` if the iframe legitimately calls it too). Tagging it `runtime`-only hides it from the agent's `tools/list`.\n- **Untagged handlers.** The default-to-`agent` behavior is a backward-compatibility convenience, not a recommendation. Always declare `audience` explicitly on new handlers.\n\n## See also\n\n- [MCP services](/architecture/mcp-services/) — isolated per-path MCP servers\n- [MCP protocol](/api/mcp-protocol/) — the `/mcp` data plane in detail\n- [Control MCP](/api/control-mcp/) — the `/control` plane in detail\n- [Architecture overview](/architecture/overview/) — three channels and the capability model"},{"path":"/architecture/benchmarks.md","title":"Benchmark methodology","description":"How benchmarks.ggui.ai measures UI-generation quality, latency, and cost — the model matrix, the five aesthetic dimensions, the three-provider judge panel, the corpus, and how to reproduce a run locally.","body":"---\ntitle: Benchmark methodology\ndescription: How benchmarks.ggui.ai measures UI-generation quality, latency, and cost — the model matrix, the five aesthetic dimensions, the three-provider judge panel, the corpus, and how to reproduce a run locally.\n---\n\n[benchmarks.ggui.ai](https://benchmarks.ggui.ai) is the public dashboard for GGUI's generation quality. It runs nightly and publishes per-cell **quality**, **latency**, and **cost** across a three-tier model matrix. This page is the methodology behind those numbers — what is measured, how it is scored, and how to reproduce a run yourself.\n\n## What it measures\n\nEvery night the harness generates UI for a fixed corpus of prompts across a **three-tier model matrix** — `fast`, `balanced`, and `premium` capability tiers, each instantiated on the three providers GGUI supports (`claude`, `openai`, `google`). Every matrix cell records three things:\n\n- **Quality** — the aesthetic score (below), 0–100.\n- **Latency** — wall-clock time to a compiled, contract-typed component.\n- **Cost** — provider spend for the generation, in USD.\n\nThe dashboard publishes **per-cell** results. It is not a provider leaderboard — see [Judge panel](#judge-panel).\n\n## Quality scoring\n\nQuality is the mean of **five aesthetic dimensions**, each weighted equally at 20% and scored 0–100:\n\n| Dimension         | What it captures                                        |\n| ----------------- | ------------------------------------------------------- |\n| Layout            | Spacing, alignment, structure, responsive behavior      |\n| Design tokens     | Correct use of `@ggui-ai/design` tokens over ad-hoc CSS |\n| Hierarchy         | Visual weighting — what reads first, second, third      |\n| Polish            | States, affordances, finish; the absence of rough edges |\n| Data presentation | How clearly the contract's data is rendered             |\n\nThe **pass threshold is 70**. A cell at or above 70 is a pass; below is a fail. The five-dimension breakdown is published alongside the composite so a regression can be traced to the dimension that moved.\n\n## Judge panel\n\nQuality is **not** scored by a single model. Each generation is judged by a **three-provider panel** — `claude`, `openai`, and `google` — all run at **temperature 0** for determinism. The published score is the **panel mean**, and the **per-cell spread** across the three judges is shown alongside it.\n\nThe panel exists to neutralize single-model bias: **no model judges only its own output**, and a generous-to-self or harsh-to-rivals bias from any one judge is diluted by the other two. The visible spread is the honesty check — a wide spread on a cell is a signal that the judges disagree, not a number to trust blindly.\n\nThis is why the dashboard publishes **per-cell scores, not a provider ranking**. The unit of truth is \"this model, this tier, on this prompt\" — rolling that up into a single \"best provider\" headline would discard exactly the per-cell, per-dimension detail the panel is designed to preserve.\n\n## Corpus\n\nThe harness runs against a **fixed set of generation prompts** — representative UI shapes that exercise the contract surface: `weather-card`, `survey-form`, `kanban-board`, and others, **plus gadget commits** (renderer-side capability flows). The corpus is fixed so that night-over-night movement reflects model and triad changes, not a shifting set of prompts.\n\n## Reproducibility\n\nThe benchmark is **source-available** — the entire harness ships in the public repo. To run it yourself:\n\n```bash\ngit clone https://github.com/ggui-ai/ggui\ncd ggui\npnpm install\npnpm --filter @ggui-ai/benchmark bench …\n```\n\nYou need a provider API key (set the relevant provider environment variable; the harness reads it the same way the live dashboard does). The benchmark **dataset is licensed CC-BY-4.0** — reuse it, cite it, build on it.\n\n## See also\n\n- [UI Generator](/architecture/ui-generator/) — the harness the benchmark exercises.\n- [benchmarks.ggui.ai](https://benchmarks.ggui.ai) — the live dashboard."},{"path":"/architecture/event-system.md","title":"Event System","description":"How user gestures travel from the renderer back to the agent — EventType vocabulary, ActionEnvelope shape, subscription rules, and the two consumer paths.","body":"---\ntitle: Event System\ndescription: How user gestures travel from the renderer back to the agent — EventType vocabulary, ActionEnvelope shape, subscription rules, and the two consumer paths.\n---\n\nUser actions originate in the renderer (on the live channel), land in the server, and reach the agent through one of two read paths. This page covers the closed vocabulary of events, the canonical envelope, how subscriptions gate what gets delivered, and how an agent or React host-helper consumer actually reads them.\n\nFor the full wire grammar of the envelope itself, see [Envelopes](/protocol/envelopes/). For the channel topology, see the [Architecture overview](/architecture/overview/).\n\n## Event vocabulary\n\nThe protocol recognizes exactly **one** event type. Every user gesture that drives a turn is a `data:submit`. The earlier `data:change` / `lifecycle:*` / `interaction:*` / `error:*` vocabulary was deleted (draft-2026-06-12) — those types never had a producer.\n\n```typescript\ntype EventType = \"data:submit\"; // the only member\n```\n\nA `data:submit` is schema-validated against the render's `actionSpec`.\n\n## The envelope\n\nEvery user gesture arrives as a flat `ActionEnvelope`:\n\n```typescript\ninterface ActionEnvelope<TPayload = JsonValue> {\n  sessionId: string; // bound at subscribe time; server rejects mismatches\n  type: EventType;\n  payload?: TPayload; // for `data:submit`: { action, data?, tool? }\n  clientSeq?: number; // client-monotonic dedup hint\n  schemaVersion?: string; // producer's PROTOCOL_SCHEMA_VERSION (advisory)\n}\n```\n\nThe envelope is intentionally flat — no nested `event` / `context` / `meta` blocks. Render-level diagnostics (device info, interface context, user identity) are captured **once** at subscribe time on the server, not per-delivery. See [Envelopes — \"Fields intentionally NOT on the envelope\"](/protocol/envelopes/#fields-intentionally-not-on-the-envelope) for the rationale.\n\n## Subscription gating\n\nDelivery gating falls out of the contract's `actionSpec`: every declared action emits a `data:submit` envelope the agent reads via `ggui_consume`. The old per-event `EventSubscription` filter (an allowlist of event types at render time) and the `DEFAULT_SUBSCRIPTION` constant were deleted from the protocol (no shims) — there is no wire-level subscribe object on render, and `data:submit` is now the only event type.\n\nThe current `ggui_render` input is `{handshakeId, props, themeId?, infra?, override?}` — `props` is required (pass `{}` when the contract declares no propsSpec), and there is no `subscribe` field.\n\n## Two reader paths, one envelope\n\nThe same `ActionEnvelope` reaches consumers through two distinct seams. **Don't conflate them.**\n\n| Consumer                               | Path                                      | Shape returned      |\n| -------------------------------------- | ----------------------------------------- | ------------------- |\n| **Agent** (server-side, LLM-driven)    | Long-polls `ggui_consume` over MCP        | `ConsumeEventEntry` |\n| **Renderer SDK** (browser, e.g. React) | Live tail on the WebSocket subscribe seam | `ActionEnvelope`    |\n\n`ggui_consume` is the agent's read path. It's render-keyed, consume-once, and the row shape (`ConsumeEventEntry`) carries a tiny bit of extra context the LLM needs to route the gesture. The WebSocket subscribe seam is what the iframe-runtime uses to deliver interaction events into the rendered component. See [MCP Protocol — Events](/api/mcp-protocol/#events) for both shapes.\n\n### Host relay + the `ai.ggui/userAction` doorbell\n\nIn MCP-Apps hosts the gesture reaches the server via the host's `ggui_runtime_submit_action` `tools/call` relay instead of the WS. If the response reports `consumerPresent: false` (no `ggui_consume` long-poll is draining — e.g. after a page reload), the iframe emits a `ui/message` whose text directs the agent to call `ggui_consume({sessionId})`, with an optional structured mirror on `content[0]._meta[\"ai.ggui/userAction\"]` — a pure pointer; the gesture itself is only ever drained via `ggui_consume`.\n\n## The MCP control surface\n\nThe agent never talks to the live channel directly — it renders UI, polls events, discovers gadgets, and browses the blueprint marketplace over MCP. The canonical agent-callable tool surface (lifecycle, capability discovery, stream emit, and blueprint marketplace) is enumerated in the [MCP Protocol](/api/mcp-protocol/) reference — that page is the single source of truth, with field-level shapes and return types. Linking out instead of duplicating here keeps this page from drifting as the tool surface evolves.\n\nThe `ggui_consume` long-poll in particular is the agent's event read path — its return shape (`ConsumeEventEntry`) is covered in [MCP Protocol — Events](/api/mcp-protocol/#events).\n\n## Outbound traffic on the same channel\n\n`ActionEnvelope` is the inbound half of the live channel. Server-to-renderer traffic on the same WebSocket arrives as `StreamEnvelope` (one delivery per named `streamSpec` channel). Contract violations are not a separate channel: an invalid inbound action is answered with a typed `error` frame (`CONTRACT_VIOLATION`) on the live channel, and a `ggui_render` / `ggui_emit` validation failure rejects the agent's own tool call. The former `_ggui:contract-error` channel and its `ContractErrorPayload` vocabulary were removed (draft-2026-06-11). Both surviving shapes are covered in [Envelopes](/protocol/envelopes/).\n\n## Generation progress events\n\nWhile the server is generating a fresh UI (no blueprint hit), it emits progress on the reserved `_ggui:lifecycle` channel (a `{type:'data'}` frame) that the renderer surfaces as a loading state. The canonical `GguiLifecyclePayload` vocabulary lives in `packages/protocol/src/types/lifecycle.ts`:\n\n```\nhandshake_started → handshake_completed → render_started → consume_polling\n```\n\nThese feed the built-in progress UI inside the rendered iframe (the iframe-runtime) — your end-user sees real-time feedback instead of a frozen iframe.\n\n## React host-helper integration\n\nIn the web consumer path the live-channel WebSocket — and the progress events above — are owned by the **iframe-runtime inside the sandboxed `<AppRenderer>`**, not by host code. The host doesn't open the socket, set a `wsEndpoint`, or handle `ActionEnvelope`s directly: the `wsUrl` is server-stamped on the render's `ai.ggui/render` slice, the iframe connects + subscribes + resumes on its own, and the progress UI animates inside the iframe.\n\n```tsx\nimport { AppRenderer } from \"@mcp-ui/client\";\nimport { useMcpAppsChat } from \"@ggui-ai/mcp-apps-react/chat-helpers\";\n\n// Mount the render; the iframe-runtime drives the live channel + progress UI.\nconst { sessions, handleAppMessage } = useMcpAppsChat({ chatEndpoint });\n// <AppRenderer ... onMessage={handleAppMessage} />\n```\n\nStructured signals the host may want (dispatch telemetry, subscribe failures, version mismatches, channel-transport picks) surface on the `ggui:observe` postMessage channel. See [React host helpers](/host-helpers/react/) for the host surface and [Error Handling → renderer-side faults](/cookbook/error-handling/#renderer-side-faults-stay-inside-the-iframe) for the observability events.\n\n## Where to next\n\n- [Envelopes](/protocol/envelopes/) — canonical live-channel wire grammar\n- [MCP Protocol](/api/mcp-protocol/) — agent-side control plane\n- [WebSocket Protocol](/api/websocket-protocol/) — renderer-side live channel\n- [Architecture overview](/architecture/overview/) — three-channel topology"},{"path":"/architecture/mcp-services.md","title":"MCP services","description":"Multi-mount MCP servers — McpService primitive, path reservations, anonymous mode, and how it differs from McpServerMount.","body":"---\ntitle: MCP services\ndescription: Multi-mount MCP servers — McpService primitive, path reservations, anonymous mode, and how it differs from McpServerMount.\n---\n\nA GGUI server is not a single MCP server. One Node process composes:\n\n- **The audience-filtered surfaces** (`/mcp` data plane, `/control` control plane) — GGUI's native tool set, optionally extended by `McpServerMount`s that aggregate external tools onto the same surfaces.\n- **Zero or more `McpService`s** — fully isolated MCP servers, each mounted at its own HTTP path with its own tool namespace.\n\nThis page covers the second half. For the audience model that drives the shared routes, see [Audience routes](/architecture/audience-routes/).\n\n## What an `McpService` is\n\nAn `McpService` is a complete, self-contained MCP server reachable at a single HTTP path you pick (`/docs`, `/playground/todos`, `/internal/billing`, …). A client connecting to that path sees exactly the tools the service declares — no ggui-native tools, no other service's tools, no audience filtering. The path **is** the namespace boundary.\n\nContrast with `McpServerMount`. A mount is a named bundle of `SharedHandler`s aggregated onto the audience-filtered routes. Mounted tools compose alongside GGUI's native tools (`ggui_render`, `ggui_handshake`, `ggui_consume`, …) so a single MCP session over `/mcp` enumerates the union of everything. Services do not compose; they isolate.\n\n```\n                           ┌─────────────────────────────────┐\n  Agent (LLM-driven) ─────▶│ /mcp                            │\n                           │   ggui-native tools             │\n                           │   + every mount's tools         │  audience-filtered\n                           │   (audience=agent|runtime)      │\n                           └─────────────────────────────────┘\n                           ┌─────────────────────────────────┐\n  Operator console  ──────▶│ /control                        │  audience-filtered\n                           │   ggui_protocol_* + ggui_ops_*  │  (audience=protocol|ops)\n                           └─────────────────────────────────┘\n                           ┌─────────────────────────────────┐\n  Docs client       ──────▶│ /docs                           │\n                           │   docs_search, docs_read,       │  isolated service\n                           │   docs_list                     │  (own namespace)\n                           └─────────────────────────────────┘\n                           ┌─────────────────────────────────┐\n  Playground user   ──────▶│ /playground/todos               │\n                           │   todos_list, todos_add,        │  isolated service\n                           │   todos_toggle, todos_delete    │  (own namespace)\n                           └─────────────────────────────────┘\n```\n\n## When to use which\n\n| You want                                                                                     | Reach for                          |\n| -------------------------------------------------------------------------------------------- | ---------------------------------- |\n| Tools that should appear alongside `ggui_render` / `ggui_consume` in a generative-UI session | `McpServerMount`                   |\n| Tools the LLM-driven agent should call as part of the same MCP connection that drives GGUI   | `McpServerMount`                   |\n| A complete, standalone MCP server at its own URL (`mcp.example.com/docs`, `…/billing`, …)    | `McpService`                       |\n| First-touch public surface for unauthenticated clients (docs lookup, marketing demos)        | `McpService` (+ `anonymous: true`) |\n| Conceptually distinct tool surfaces that should NOT collide on names with each other         | `McpService` (one per surface)     |\n\nRubric:\n\n- **Composes with GGUI's core tools?** Mount.\n- **Replaces GGUI's core tools at its own URL?** Service.\n\nIf you find yourself disabling ggui-native tools on a mount because they don't make sense for the caller, you wanted a service. If you find yourself proxying GGUI's `tools/list` output through a service to bolt on extra tools, you wanted a mount.\n\n## The `McpService` shape\n\n```ts\nimport type { SharedHandler } from \"@ggui-ai/mcp-server-handlers\";\nimport type { ZodRawShape } from \"zod\";\n\ninterface McpService {\n  /**\n   * Human-readable service identifier. Surfaced in validation\n   * errors and telemetry. No uniqueness constraint across services\n   * — only `path` must be unique.\n   */\n  readonly name: string;\n\n  /**\n   * HTTP path the service mounts at (e.g. `/docs`,\n   * `/playground/todos`). Validated at compose time.\n   */\n  readonly path: string;\n\n  /**\n   * Tool handler bundle. Same `SharedHandler` shape ggui-native\n   * handlers and mount handlers use.\n   */\n  readonly handlers: ReadonlyArray<SharedHandler<ZodRawShape, ZodRawShape>>;\n\n  /**\n   * Auth-optional: a valid bearer still resolves to the real\n   * identity; missing/invalid credentials fall back to a\n   * synthesized anonymous builder. Default `false` — same auth\n   * posture as `/mcp`.\n   */\n  readonly anonymous?: boolean;\n}\n```\n\nField-by-field:\n\n- **`name`** — diagnostic-only. Surfaces in `validateMcpServices` error messages and composition telemetry so an operator with several services can tell which one is misconfigured. Does NOT appear on the wire; tool names stay whatever each handler declares.\n- **`path`** — the HTTP path Express mounts the service handler at. Branded `ServicePath` after passing `validateServicePath`. Must be unique across the `mcpServices` array.\n- **`handlers`** — `SharedHandler[]`, the same canonical shape every ggui-native handler satisfies. The server registers each handler through `buildMcpServer`'s regular path; validation, logging, and output-schema parsing all happen uniformly.\n- **`anonymous`** — makes auth optional. Default `false`. See [Anonymous mode](#anonymous-mode) below.\n\n## Path reservations\n\nNine paths are reserved at validation time. Declaring a service at any of them throws at server-construction:\n\n| Path           | Why reserved                                                                                                                                  |\n| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |\n| `/`            | Root — must not be swallowed by a service router.                                                                                             |\n| `/mcp`         | The agent-facing audience-filtered MCP route.                                                                                                 |\n| `/control`     | The control plane (design-time protocol + operator tools).                                                                                    |\n| `/ws`          | The live-channel WebSocket upgrade path.                                                                                                      |\n| `/health`      | Reserved for health probing — the live endpoint is `/ggui/health`; `/health` is held back so a service can never shadow a future short alias. |\n| `/.well-known` | RFC-reserved discovery prefix (OAuth metadata, security.txt, …).                                                                              |\n| `/oauth`       | OAuth dance endpoints (authorize / token / register).                                                                                         |\n| `/_ggui`       | Internal GGUI control surfaces (admin console, pairing, debug routes).                                                                        |\n| `/ggui`        | Same — public-facing `/ggui/*` endpoints.                                                                                                     |\n\nA typo in a host config (`path: '/mpc'` instead of `/mcp`) can no longer silently shadow OAuth discovery, health, or per-app traffic.\n\n:::caution\nReservations apply to **exact equality**, not to prefixes you build on top. A service at `/.well-known/example` is rejected because of the `/.well-known` reservation, but a service at `/wellknown-example` is fine. Pick a distinct first segment.\n:::\n\n## Path validation rules\n\n`validateServicePath` enforces:\n\n1. **Regex** `^/[a-zA-Z0-9_/-]+$` — must start with `/` and contain only letters, digits, `-`, `_`, and `/`. No whitespace, no `.`, no path traversal.\n2. **No trailing slash** — `/docs/` is rejected; use `/docs`. Prevents trailing-slash variant collisions where `/docs` and `/docs/` would resolve to different routes.\n3. **Non-empty after the leading slash** — at least one character after `/`.\n4. **Not a reserved path** — see the table above.\n\nThrowing happens at server construction, before any request is served — misconfiguration cannot become a runtime mystery.\n\n## Compose-time invariants\n\n`validateMcpServices(services)` walks the whole array and enforces:\n\n- **Non-empty `name`** on every entry. The name appears in every other error message; empty names defeat the diagnostic.\n- **`path` passes `validateServicePath`** — all the rules above.\n- **Service paths are unique** across the `mcpServices` array. Two services cannot mount at the same path.\n- **Every handler declares a non-empty `outputSchema`.** An empty `ZodRawShape` (`{}`) silently strips `structuredContent` at the MCP SDK boundary — the handler can return `{ items: [...] }` and the wire answer is `{}`. Operators hitting this see success-looking responses with missing data and no diagnostic. Rejected at compose time so the failure arrives with the service + tool names attached.\n- **No `audience` tag on service handlers.** Services bypass audience filtering entirely — the path IS the audience. An explicit `audience: ['ops']` on a service handler is silently meaningless. Rejected loudly.\n- **Tool names are unique within a service.** Two handlers declaring the same `name` inside one service collide; one would shadow the other at registration. Rejected.\n- **Cross-service tool-name collisions ARE allowed.** Services are isolated namespaces. A client connects to one path and only ever sees that path's tools, so `docs_search` on `/docs` and `docs_search` on `/internal/docs` coexist without ambiguity. This is by design — collapsing into a global tool namespace would defeat the isolation that makes services worth having.\n\n:::note\n`validateMcpServices` is pure and idempotent — safe to call multiple times. It returns the input reference unchanged when the input is non-empty and valid; absent or `undefined` input resolves to a fresh empty array.\n:::\n\n## Anonymous mode\n\n`anonymous: true` makes auth **optional**, not skipped. The server always attempts to resolve a presented bearer — a valid credential resolves to the real identity (letting one service mix public reads with authenticated capabilities); only a missing or invalid credential makes the binding layer fall back to the synthesized:\n\n```ts\n{\n  identity: { kind: 'builder' },\n  source: 'anonymous',\n}\n```\n\nTwo things to note about the shape:\n\n- **`identity.kind` does NOT widen.** The `Identity` union still has its three variants (`'builder'` / `'user'` / `'app'`). Anonymous traffic collapses to `'builder'` so handlers that pattern-match on `kind` keep working without an extra arm. Adding a `'anonymous'` kind would force every consumer in the codebase to add a defensive case — the synthesized `'builder'` is the lighter touch.\n- **The signal lives on `source`.** `AuthResult.source` carries a dedicated `'anonymous'` variant. Handlers that need to distinguish \"this request was authenticated\" from \"this request was let through anonymously\" read `source` directly — pattern-matching on `identity.kind` alone cannot answer the question.\n\n### When to use it\n\n- **Read-only public surfaces.** Documentation lookup, public catalog browse, marketing demos.\n- **First-touch onboarding flows** where requiring a bearer token would block the use case (a brand-new visitor to `mcp.example.com/docs` has nothing to present).\n- **Server-to-server probes** that verify protocol shape without claiming an identity.\n\n### When NOT to use it\n\n- **Any write path.** An anonymous caller has no accountability surface; you cannot audit who created the row.\n- **Anything tenant-scoped.** `ctx.appId` for an anonymous request collapses to the single builder. Reads will return data the caller has no business seeing, or writes will land in a tenant they don't own.\n- **Anything sensitive enough that you'd want rate limits per-caller.** Anonymous mode pairs with per-IP / per-session rate limits (the `RateLimiter` seam composes the same way for authenticated and anonymous paths), but it cannot give you per-user attribution.\n\n:::tip\nHandlers can defend in depth by reading `source` and rejecting `'anonymous'` for sensitive paths even on a service declared `anonymous: true`. The flag opens the door at the binding layer; handlers retain the right to slam it on writes.\n:::\n\n## Wiring it up\n\nPass the service array to `createGguiServer`:\n\n:::note[Service packages are monorepo-internal]\nThe `@ggui-private/mcp-docs` and `@ggui-private/mcp-playground-*` packages used below are first-party GGUI services that ship inside the monorepo — they're not published to npm. The example shows the shape; substitute your own service-package imports and handler factories when wiring your own services. The `McpService` primitive itself is in `@ggui-ai/mcp-server`, which IS public.\n:::\n\n```ts\nimport { createGguiServer } from \"@ggui-ai/mcp-server\";\n// monorepo-internal first-party services (not published):\nimport { createDocsHandlers, loadDocsCorpus } from \"@ggui-private/mcp-docs\";\nimport {\n  createPlaygroundTodosHandlers,\n  createInMemoryTodoStore,\n} from \"@ggui-private/mcp-playground-todos\";\n\nconst corpus = await loadDocsCorpus(\"./docs\");\nconst todoStore = createInMemoryTodoStore();\n\nconst server = await createGguiServer({\n  // ... ggui-native options (renderChannel, blueprintStore, …)\n\n  mcpServices: [\n    {\n      name: \"docs\",\n      path: \"/docs\",\n      handlers: createDocsHandlers({ corpus }),\n      anonymous: true, // read-only doc lookup; no token required\n    },\n    {\n      name: \"playground-todos\",\n      path: \"/playground/todos\",\n      handlers: createPlaygroundTodosHandlers({ store: todoStore }),\n      // no `anonymous: true` — handlers throw when ctx.userId is missing\n    },\n  ],\n});\n\nawait server.listen(6781);\n```\n\nAfter `listen()`:\n\n- `POST /mcp` — ggui-native + every mount's tools (audience-filtered).\n- `POST /docs` — `docs_search`, `docs_read`, `docs_list`. No auth required.\n- `POST /playground/todos` — `todos_list`, `todos_add`, `todos_toggle`, `todos_delete`. Auth-gated (handlers reject anonymous callers).\n\nThe audience-filtered routes and the service routes coexist on the same Node process, the same Express app, the same WebSocket binding. There is no second server to run.\n\n## Examples in the wild\n\nThree first-party services are built on this primitive for the hosted deployment:\n\n- **`/docs`** — `@ggui-private/mcp-docs`. Three read-only tools (`docs_search`, `docs_read`, `docs_list`) over the GGUI documentation corpus. Anonymous-mode; the canonical \"public surface\" example. See [MCP Docs Service](/api/mcp-docs/).\n- **`/playground/todos`** — `@ggui-private/mcp-playground-todos`. Four tools (`todos_list`, `todos_add`, `todos_toggle`, `todos_delete`) for the landing-page playground. Auth-gated per-user state (handlers reject anonymous callers). See [Playground · todos](/hosted/playgrounds/todos/).\n- **`/playground/the-million-dollar-homepage`** — `@ggui-private/mcp-playground-mdh`. Million-Dollar Homepage playground service. See [Playground · MDH](/hosted/playgrounds/mdh/).\n\nGGUI's own `/control` plane is the fullest example of the pattern: one anonymous-capable surface co-hosting public design-time tools with ops tools that re-impose auth per-tool — possible precisely because `anonymous` is auth-optional, so a presented bearer still resolves to the real identity.\n\nFor the operator-facing surface alongside these services, see [Control MCP](/api/control-mcp/). For running an analogous service stack under your own hostname, see [`ggui serve`](/cli/serve/).\n\n## Where to next\n\n- [Audience routes](/architecture/audience-routes/) — the audience model behind `/mcp` and `/control`\n- [Architecture overview](/architecture/overview/) — the three-channel topology services live inside\n- [Event System](/architecture/event-system/) — live-channel traffic is shared by every service on the same host\n- [MCP Docs Service](/api/mcp-docs/) — the canonical anonymous-mode example\n- [Control MCP](/api/control-mcp/) — the operator-facing surface\n- [Playground · todos](/hosted/playgrounds/todos/) and [Playground · MDH](/hosted/playgrounds/mdh/) — authenticated-service examples\n- [`ggui serve`](/cli/serve/) — self-host an analogous service stack"},{"path":"/architecture/overview.md","title":"Architecture","description":"How GGUI is wired — three channels, a symmetric capability model, a generation pipeline, and a four-tier artifact registry. Protocol-level; implementation-agnostic.","body":"---\ntitle: Architecture\ndescription: How GGUI is wired — three channels, a symmetric capability model, a generation pipeline, and a four-tier artifact registry. Protocol-level; implementation-agnostic.\n---\n\nThis page is the **protocol's architecture** — what every GGUI implementation must do, independent of how it's deployed. For deployment shapes, see [Self-Hosted](/self-hosted/pairing/) and [Reference deploys](/self-hosted/reference-deploys/).\n\n## Three actors\n\n```\nAgent (LLM-driven)\n   │\n   │ MCP\n   ▼\nServer  ◀───── WebSocket (live) ──────▶  Renderer (iframe / standalone page)\n   │                                            │\n   │           bootstrap (bundle fetch)         │\n   └────────────────────────────────────────────┘\n```\n\n- **Agent** — your code with an LLM in the loop. Speaks to the server over MCP.\n- **Server** — speaks MCP outward, orchestrates generation, routes events. Hosts the artifact registry the renderer pulls from. Runs at your URL via `ggui serve`, or hosted at `mcp.ggui.ai`.\n- **Renderer** — an iframe (or standalone page) hosting the generated component. Sends user actions back to the server through the host's `tools/call` relay.\n\n## Three channels\n\nGGUI's wire is split across three orthogonal channels. Each has one job.\n\n| Channel       | Direction         | Purpose                                                                                                                                                                                                                                                                                                                                 |\n| ------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Bootstrap** | Server → Renderer | One-shot fetch of the compiled component bundle when the iframe first loads. Any gadgets bound to the session load behind a `<link rel=\"modulepreload\" integrity>` SRI gate.                                                                                                                                                            |\n| **MCP**       | Agent ↔ Server    | Control plane. `ggui_handshake`, `ggui_render`, `ggui_update`, `ggui_amend`, `ggui_consume`, `ggui_emit`, `ggui_get_session`.                                                                                                                                                                                                           |\n| **Live**      | Renderer ↔ Server | WebSocket at `ws://127.0.0.1:6781/ws` (self-hosted default; hosted at `wss://mcp.ggui.ai/ws`). Server deliveries outbound (`StreamEnvelope`, props updates, drain acks); contract violations on an inbound action are answered with a typed `error` frame (`CONTRACT_VIOLATION`) on this channel — nothing lands on the consume buffer. |\n\nThe rendered view's user actions reach the server through the host's MCP-Apps `tools/call` relay to `ggui_runtime_submit_action` — the spec-canonical dispatch path; the server appends the gesture to the pipe `ggui_consume` drains. The live WebSocket's job is server → renderer delivery (stream emits, props updates, drain acks).\n\nThe channels are independent. The renderer can drop and reconnect the live channel without disturbing an agent's MCP turn; the agent can `ggui_render` repeatedly without ever touching the live channel.\n\n:::note[Multiple MCP services on one server]\nBeyond the two audience-filtered surfaces (`/mcp` data plane, `/control` control plane), additional standalone services can be mounted at their own paths (e.g. `https://your-server/docs`, `https://your-server/playground/todos`). See [MCP services](/architecture/mcp-services/) and [Audience routes](/architecture/audience-routes/).\n:::\n\n→ See [Protocol overview](/protocol/overview/) for the formal three-channel spec.\n\n## Capability model\n\nGGUI has two **symmetric** capability surfaces — one for what the agent can do, one for what the renderer can render. Both are operator-bounded and declared per-app.\n\n|                 | Renderer side                                                                           | Agent side                                                                    |\n| --------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |\n| **Unit**        | **gadget**                                                                              | **tool**                                                                      |\n| **Catalog**     | `clientCapabilities.gadgets`                                                            | `agentCapabilities.tools`                                                     |\n| **Role**        | Wraps a 3rd-party browser library (Leaflet, Mapbox, Chart.js) into an LLM-callable hook | Gives the agent a function to invoke (e.g. `searchContacts`, `createInvoice`) |\n| **Authored as** | `ggui.gadget.json` manifest                                                             | MCP tool code                                                                 |\n| **Bound at**    | iframe boot (SRI-verified)                                                              | session start                                                                 |\n\nPlus a third primitive — **blueprints** — which aren't capabilities but **cached recipes** (pre-composed UIs). A blueprint hit short-circuits fresh generation.\n\n→ See [Gadgets SDK](/sdk/gadgets/), [MCP Protocol](/api/mcp-protocol/), [Marketplace](/hosted/marketplace/).\n\n## Generation pipeline\n\nRendering is a two-call flow — `ggui_handshake` negotiates, `ggui_render` commits:\n\n```\n0. NEGOTIATE  ggui_handshake({intent, blueprintDraft}) searches the blueprint cache by\n              contract shape (exact contractHash hit short-circuits; semantic similarity\n              otherwise) and returns a suggestion\n1. COMMIT     ggui_render({handshakeId, props}) consumes the handshake; a cache hit\n              reuses the stored blueprint (zero generation calls)\n2. GENERATE   Otherwise, run the server's UI generator (@ggui-ai/ui-gen):\n              workflow → impl → check → derive\n3. COMPILE    TSX → JS via esbuild (~20-50ms)\n4. DELIVER    Renderer fetches the compiled bundle on the bootstrap channel\n```\n\nPublished artifacts (gadgets, blueprints) on the marketplace registry pick up an author signature at _publish_ time — Ed25519 (publisher keypair) for private artifacts, sigstore keyless (OIDC) for public ones — see [Marketplace](/hosted/marketplace/). Fresh generations are session-scoped and skip that step.\n\nThe generator (step 2) is a bounded harness: pick a workflow (`single_pass`, `staged`, `staged-concurrent`), run the LLM-driven impl phase, run a check leg (typecheck, render-smoke, per-axis assertions), and on failure derive a revised harness and retry up to `maxIterations`. Output: a TypeScript-typed contract plus a compiled component module.\n\n→ See [UI Generator](/architecture/ui-generator/) for the harness internals.\n\n## Artifact registry\n\nGadgets and blueprints resolve through a four-tier waterfall on every push:\n\n```\n1. App-local        ggui.json#app.gadgets, ggui.json#blueprints.include\n                    (plus installed artifacts under .ggui/installed-blueprints/)\n2. Per-org private  operator's private registry (artifacts with visibility:\"private\")\n3. Public           registry.ggui.ai (marketplace)\n4. Fall back        fresh generation\n```\n\nA blueprint is keyed by a stable `blueprintId`; its `contractHash` — the RFC 8785 canonical-JSON hash of its `DataContract`, scoped per `(appId, contractHash)` — groups variants and is the cache lookup key. An exact contractHash hit short-circuits to score 1.0; otherwise a multi-axis semantic search (contract embedding, structural fingerprint, variance tags, intent) ranks candidates. Install the same `(scope, name, version)` on two different servers and the matcher returns the byte-identical UI on both.\n\n→ See [Marketplace](/hosted/marketplace/), [Self-Hosted Registry](/sdk/self-hosted-registry/).\n\n## Deployment shapes\n\nThe same protocol runs in two shapes:\n\n|                  | Self-hosted                                                         | Hosted                            |\n| ---------------- | ------------------------------------------------------------------- | --------------------------------- |\n| **MCP endpoint** | your URL via `ggui serve`                                           | `mcp.ggui.ai`                     |\n| **WS endpoint**  | `ws://127.0.0.1:6781/ws` (or your URL)                              | `wss://mcp.ggui.ai/ws`            |\n| **Auth**         | pluggable `AuthAdapter` + optional OAuth 2.1 (`ggui serve --oauth`) | OAuth                             |\n| **Registry**     | your registry (or none)                                             | `registry.ggui.ai`                |\n| **Generation**   | in-process                                                          | managed                           |\n| **Pick if**      | \"I need data residency, custom auth, or air-gapped\"                 | \"I just want to ship an agent UI\" |\n\nBoth speak the same wire. Switching between them is configuration, not code.\n\n→ See [Build an agent on hosted GGUI](/quickstart/hosted-agent/) to try the hosted path.\n\n## Where to next\n\n- [Protocol overview](/protocol/overview/) — formal spec\n- [How GGUI works](/how-it-works/) — narrative walk-through of the five moments\n- [Event System](/architecture/event-system/) — live-channel event flow in detail\n- [UI Generator](/architecture/ui-generator/) — the generator harness"},{"path":"/architecture/ui-generator.md","title":"UI Generator","description":"The bounded harness that turns a data contract into a compiled React component — workflow, check, derive, repeat.","body":"---\ntitle: UI Generator\ndescription: The bounded harness that turns a data contract into a compiled React component — workflow, check, derive, repeat.\n---\n\nThe UI generator runs when a [blueprint match](/architecture/overview/#generation-pipeline) misses and GGUI has to build a component from scratch. It takes a **data contract** (`PropsSpec` + `ActionSpec` + `StreamSpec` + `ContextSpec`) and returns a compiled, contract-typed React module — typically a handful of LLM turns; cache hits via blueprints are what avoid this cost entirely.\n\n## The harness\n\nA **harness** is a per-contract execution plan derived from the contract's risk and axes. It bundles three things:\n\n- **Workflow** — the topology of LLM phases and tasks. Today the dispatcher always picks `single_pass`; `staged` and `staged_concurrent` are registered as reserved-future topologies for risk-tier routing that has not yet shipped.\n- **Prompt + boilerplate** — the system prompt and fragment set that tell the LLM _how_ to author against `@ggui-ai/design`, the active [gadgets](/glossary/#gadget-renderer-side-capability), and the contract.\n- **Check leg** — the post-conditions the output must satisfy.\n\nGeneration runs the workflow to produce source, runs the check leg, and — if checks fail — derives a revised harness and re-runs, bounded by `maxIterations`.\n\n```\nworkflow → source → check → pass? ─► return\n                       │\n                       └─ fail → derive (swap fragments / upgrade workflow / adjust prompt) → loop\n```\n\n## Workflows\n\n| Workflow            | Status                              | Shape                                       |\n| ------------------- | ----------------------------------- | ------------------------------------------- |\n| `single_pass`       | **Live** — only dispatched workflow | One impl turn.                              |\n| `staged`            | Reserved (registered, not routed)   | Plan → execute.                             |\n| `staged_concurrent` | Reserved (registered, not routed)   | Plan → parallel skeleton tasks → integrate. |\n\n`pickWorkflow` is deliberately conservative — every classification today routes to `single_pass`. The staged topologies are wired up so a future risk-tier router can dispatch to them without a schema change, but changing the picker is a first-class experiment with its own bench gate. All three feed the same check + derive loop; the workflow only changes how source is produced, not how it's validated.\n\n## Plain-text impl loop (no tool-call ceremony)\n\nThe impl phase is structured so the LLM doesn't waste turns on deterministic ceremony:\n\n1. The LLM receives **everything pre-injected** — primitives docs, design tokens, the data contract with examples, gadget capability cards.\n2. The LLM writes the component as **plain text**. No tool calls required.\n3. The system **auto-runs** `self_check` and `compile_component` on the emitted source.\n4. Failures are fed back as a structured diff for the next turn to fix.\n\nThis is what keeps generations short. On the [public benchmark](https://benchmarks.ggui.ai), the median generation takes two turns, and about four in five finish in one to three. Turns ≥ 6 is a triad-misalignment signal, not a turn-budget problem.\n\n## Check leg\n\nEvery workflow runs the same checks before returning:\n\n- **TypeScript** — the emitted source is compiled against the real `@ggui-ai/design` type definitions via the TypeScript compiler API on a virtual filesystem. Catches wrong prop types, missing required props, invalid imports, and `strictNullChecks` violations.\n- **Render smoke** — `ReactDOMServer.renderToString()` with contract-derived sample props. Catches `undefined.toLowerCase()`-class runtime errors before the component reaches the renderer.\n- **Per-axis assertions** — axis-specific checks derived from the contract (e.g., does an `interactive` axis include a focusable element? does a `submit` action have a matching form?).\n\nIf any leg fails, the harness derives a revised configuration and loops. If the final iteration still fails, generation returns `ok: false` with `reason: \"max-iterations\"` and the last source, compiled output, and check result attached for diagnostics — the caller decides whether to surface a fallback, retry with a different harness, or report the failure.\n\n## Multi-provider transport\n\nThe harness is provider-agnostic. Only the LLM transport differs:\n\n- **Claude** (Anthropic) — raw API or Claude Agent SDK.\n- **OpenAI** — Responses API or OpenAI Agents SDK.\n- **Google** (Gemini) — GenAI API or Google ADK.\n\nWorkflow, check, and derive are identical across providers. Switching providers does not change what gets generated, only the per-turn cost and latency profile.\n\nA deployment pins its model in `ggui.json#generation.model` (`provider:model`, e.g. `anthropic:claude-haiku-5-5`); the `GGUI_GENERATION_MODEL` env var overrides the manifest (precedence: `GGUI_GENERATION_MODEL` env > `ggui.json#generation.model` > per-provider default), and an agent may override per render via `ggui_render({infra: {model}})`. The protocol registry is the allowlist of accepted ids. Its lineup is `claude-haiku-5-5`, `claude-sonnet-5-5`, `claude-opus-5-5` and `claude-fable-5-1` (the most capable) for Anthropic, and `gpt-6-luna` for OpenAI; it also accepts the other GPT-6 and GPT-5.x ids, and the legacy models (`claude-haiku-4-5`, `claude-sonnet-5`, `claude-opus-5`, `claude-fable-5`, the 4.6–4.7 generation) — pick by the cost and capability you want; an id outside the registry is rejected at parse time rather than passed through. Hosted GGUI's pool default — what a pool-funded render runs when nothing names a model — is `openai/gpt-6-luna`. `generation.keySource: 'own' | 'managed'` declares whose provider key funds generation — self-hosted deployments always use their own key.\n\n## Output shape\n\nA successful generation returns:\n\n- A **compiled component module** (TSX source compiled to JS).\n- A **typed data contract** — the same `PropsSpec` / `ActionSpec` / `StreamSpec` / `ContextSpec` that was the input, now frozen as the runtime wire shape.\n- A **blueprint candidate** — the contract (hashed via RFC 8785 to `contractHash`) plus its variance (`variantKey`), which the registry stores under a stable `blueprintId` for the next handshake's cache match.\n\nThe contract is enforced again at the MCP handler boundary so that what the agent renders matches what the component was generated to render.\n\n→ See [Architecture overview](/architecture/overview/) for where the generator fits in the render pipeline, and the [Glossary](/glossary/) for `harness`, `blueprint`, `gadget`, `contract`."},{"path":"/build/dev-loop.md","title":"The dev loop","description":"Iterate against a local GGUI runtime, then point the same agent at hosted GGUI by swapping two environment variables — including the endpoint-shape trap that bites on the first deploy.","body":"---\ntitle: The dev loop\ndescription: Iterate against a local GGUI runtime, then point the same agent at hosted GGUI by swapping two environment variables — including the endpoint-shape trap that bites on the first deploy.\n---\n\nYour agent talks to GGUI over MCP and nothing else. That single fact is what makes the\nlocal-to-hosted move small: the tools, the contracts, and the system prompt are identical\non both sides, so promoting an agent from your laptop to `mcp.ggui.ai` is a change of URL\nand bearer, not a change of code.\n\nThis page is the loop that follows from that: iterate locally where restarts are instant\nand generation runs on your own provider key, then flip the endpoint when you need a real\nuser in front of the render.\n\n## The two local servers\n\nThe CLI ships two local runtimes and they are not interchangeable.\n\n| | [`ggui dev`](/cli/dev/) | [`ggui serve`](/cli/serve/) |\n| --- | --- | --- |\n| Default bind | `127.0.0.1:6780` | `127.0.0.1:6781` |\n| Serves MCP | No | Yes, at `/mcp` |\n| For | Iterating on blueprints and gadgets in the dev hub | Running the protocol the way production runs it |\n\n`ggui dev` is a blueprint registry plus a dev hub — it indexes the `ggui.ui.json` manifests\ndeclared in `ggui.json#blueprints.include`, previews them at `/hub/preview?ui=<id>`, and with\n`--agent <entry>` supervises your agent process in the same shell. It deliberately does not\nrun an MCP server.\n\n`ggui serve` is the one your agent connects to. It boots `@ggui-ai/mcp-server` and, by\ndefault, the agent declared in `ggui.json#agent.entry`. Both can run side by side; the ports\ndo not collide.\n\n```bash\nggui serve --dev-allow-all   # MCP at http://127.0.0.1:6781/mcp, any bearer accepted\nggui dev                     # blueprint hub at http://127.0.0.1:6780/hub\n```\n\n`--dev-allow-all` accepts any bearer (or none) as a `builder` identity. It is a loopback\nconvenience and the boot banner says so — never put it on a public URL. Without it, `ggui serve`\nrequires a pair-minted bearer.\n\n## The endpoint shape differs, and it is the first thing that breaks\n\nThe two deployments do not spell their MCP endpoint the same way.\n\n- **Local `ggui serve`** serves MCP at the `/mcp` sub-path: `http://127.0.0.1:6781/mcp`.\n- **Hosted GGUI** serves each app at its bare per-app path: `https://mcp.ggui.ai/apps/<appId>`.\n\nThe `/mcp` suffix is a local-`ggui serve` convention only. Append it to the hosted URL and the\nper-app pod returns 404, which surfaces as an agent that discovers zero GGUI tools rather than\nas an obvious connection error. Carry the suffix over from your local config and this is the\nsymptom you will see.\n\n## Swap the deployment with environment variables\n\nThe reference samples read the endpoint and the bearer from the environment, so the two\ndeployments are two `.env.local` files rather than two code paths.\n\n<Tabs syncKey=\"deployment\">\n<TabItem label=\"Hosted\">\n\n```bash\n# .env.local — hosted ggui\nGGUI_MCP_URL=https://mcp.ggui.ai/apps/aB3kP9xY\nGGUI_MCP_BEARER=ggui_user_xxxxxxxx\nANTHROPIC_API_KEY=sk-ant-...\n```\n\nBoth values come out of [the GGUI console](/hosted/console/) or the CLI: `ggui login`, then\n`ggui keys create --name my-agent` mints the `ggui_user_*` connector key. The app ID is an opaque\n8-character string with no prefix — the app's page shows the whole endpoint, `mcp.ggui.ai/apps/<appId>`,\nready to copy.\n\n</TabItem>\n<TabItem label=\"Self-hosted\">\n\n```bash\n# .env.local — local ggui serve\nGGUI_MCP_URL=http://127.0.0.1:6781/mcp\nGGUI_MCP_BEARER=dev\nANTHROPIC_API_KEY=sk-ant-...\n```\n\n`dev` is accepted because `ggui serve --dev-allow-all` accepts any bearer. Under the default\nstrict-auth posture, use a pair-minted bearer instead — see [Pair a client app](/self-hosted/pairing/).\n\n</TabItem>\n</Tabs>\n\nNothing else moves. The agent wiring reads both values and hands them to the MCP client:\n\n```typescript\nconst mcpServers = {\n  ggui: { url: process.env.GGUI_MCP_URL ?? \"http://localhost:6781/mcp\" },\n};\n```\n\nThat is the shape used by every sample in\n[`samples/agents/`](https://github.com/ggui-ai/ggui/tree/main/samples/agents) — Claude Agent SDK,\nOpenAI Agents SDK, and Google ADK all read the same two variables.\n`@ggui-ai/agent-server` resolves the bearer as `opts.bearer ?? process.env.GGUI_MCP_BEARER ?? \"dev\"`\nand sends it as `Authorization: Bearer <token>` on every MCP request. Wiring the SDK by hand\ninstead? [The Claude agent example](/examples/claude-agent/) shows the explicit `headers` form.\n\n## Let the CLI write the hosted half\n\n[`ggui deploy`](/cli/deploy/) does the whole hosted-side provisioning in one idempotent pass: sign in, create\nthe cloud app and write its ID into `ggui.json`, mint a connector key into `.env.local` as\n`GGUI_MCP_BEARER`, upload your local blueprint pool, push declared gadgets and `publicEnv`, and\nfinally write `GGUI_MCP_URL`. Re-run it after a partial failure and it resumes from where it\nstopped.\n\n```bash\nggui deploy\n```\n\nIt writes the endpoint in the correct bare `/apps/<appId>` shape, which is the practical reason\nto prefer it over hand-editing the URL. An existing `GGUI_MCP_URL` is never clobbered — the first\ndeploy writes it, later ones leave your override alone. Add `--push-keys` to also push your\nprovider key to the app so `keySource=own` renders run on your own model account.\n\n## What actually changes when you cross over\n\n**Who pays for generation.** Local `ggui serve` calls your provider directly with the key in\nyour environment — no GGUI credits involved. Hosted renders draw down your credit balance\nunless the app runs on your own provider key, in which case GGUI skips the charge entirely.\nSee [Credits, billing & BYOK](/hosted/billing/).\n\n**Auth posture.** `--dev-allow-all` is a loopback-only shortcut. Hosted requires a real\n`ggui_user_*` connector key per app, revocable from the CLI.\n\n**The blueprint pool.** Blueprints you matured locally are worth carrying over — a hosted app\nthat already has them matches from cache instead of paying to synthesize the same UI again.\n`ggui push` compiles and uploads the local pool to a cloud app (`ggui deploy` runs it as one of\nits steps); `ggui export-pool` writes the pool to a directory that another `ggui serve` can load\nwith `--seed-pool <dir>`.\n\n**Rate limits.** Hosted enforces per-key limits that a loopback server does not — see\n[Rate limits](/hosted/rate-limits/).\n\nWhat does not change: the `ggui_*` tool surface, the handshake-render-consume sequence,\n`GGUI_AGENT_SYSTEM_PROMPT`, your contracts, and your component code. That is the portability\nclaim, and it is testable — see [Portability](/hosted/portability/).\n\n## A loop that works\n\n1. `ggui serve --dev-allow-all` in one shell, your agent pointed at `http://127.0.0.1:6781/mcp`.\n2. `ggui dev` in another when you are shaping blueprints; edit `ggui.ui.json`, reload the hub,\n   the registry re-indexes on every load.\n3. `ggui dev --no-serve` in CI — it loads and validates every manifest and exits non-zero on a\n   malformed one, without binding a socket.\n4. When you need a real user in front of the render, `ggui deploy`, then restart the agent with\n   the hosted `.env.local`.\n5. Need a public URL without leaving the local runtime? Run a tunnel beside it\n   (`cloudflared tunnel --url http://localhost:6781`) and point the client at\n   `https://<tunnel>/mcp`.\n\n## See also\n\n- [`ggui dev`](/cli/dev/) — every flag, the dev-hub route table, agent supervision\n- [`ggui serve`](/cli/serve/) — auth postures, persistence bundle, reference deploys\n- [`ggui deploy`](/cli/deploy/) — every step of the hosted-side provisioning pass, gates and all\n- [Build an agent on hosted GGUI](/quickstart/hosted-agent/) — the hosted path end to end\n- [Self-hosted quickstart](/quickstart/self-hosted/) — the self-hosted bootstrap\n- [Connector keys](/hosted/connector-keys/) — what a `ggui_user_*` key authorizes"},{"path":"/changelog.md","title":"What's new","description":"Release notes and protocol-affecting changes for GGUI — the running record since the public preview opened.","body":"---\ntitle: What's new\ndescription: Release notes and protocol-affecting changes for GGUI — the running record since the public preview opened.\n---\n\nThe GGUI protocol is a pre-1.0 draft (currently `draft-2026-09-10`) — it can still change. This page tracks releases and protocol-affecting changes as they ship. For the semver rules behind what counts as breaking, see [Version policy](/protocol/version-policy/).\n\n## Unreleased — next: v0.29.0\n\n- Changed, in `@ggui-ai/protocol` (#1803): `MODEL_LINEUP` — the models a picker shows first — gains Claude Sonnet 5.5 (`anthropic/claude-sonnet-5-5`) and drops Claude Sonnet 5 (`anthropic/claude-sonnet-5`), which becomes `legacy` and stays routable: the same replace shape as Opus 5 → Opus 5.5 (#1266) and Haiku 4.5 → Haiku 5.5 (#1743). `DEFAULT_MODEL` (Claude Haiku 5.5) does not move. The MCP wire is unchanged.\n- Fixed, in `@ggui-ai/cli` and `@ggui-ai/mcp-server` (#1907): `ggui serve` no longer adds a duplicate cache row for each installed blueprint on every restart. When the vectors persist (the default `.ggui/persistent/vectors.sqlite`, or `storage.vectors` with the `sqlite` driver), their blueprint identity index now persists beside them in the same database file. Before, the index lived in memory and came back empty, so each restart registered every installed blueprint again. `resolveStorageFromConfig` returns the index as `index`; pass it to `createGguiServer({ vectors, index })`. On shutdown, `ggui serve` now waits up to five seconds for installed-blueprint registrations still in flight, so a stop cannot leave a cached blueprint without its index entry. This change does not remove rows already duplicated in an existing `vectors.sqlite`.\n- Changed, in `@ggui-ai/design`, `@ggui-ai/iframe-runtime` and `@ggui-ai/ui-gen`: under a host that shows a view fullscreen, a chat-width card now keeps its natural height in its own frame (the theme's card surface, a hairline ring of its ink, its radius) at the top of the surface, on the host's sunken pane ground (`--ggui-color-sunken`, set through the render shell's `--ggui-shell-background`), instead of stretching to fill it. Generated cards export the canvas they were composed for (`gguiMountCanvas`), and the runtime reads it beside the default export. Every other card, and any card compiled before this release, composes exactly as before: a canvas card still fills under fullscreen, and an inline card is unchanged. The visual evaluator captures and describes such a card the same way. New exports from `@ggui-ai/design/rendering`: `resolveMountFit({ canvas, displayMode })`, the one rule, with `MountFit` (`'card' | 'framed-card' | 'fill'`, now the type of `composeThemeCss`'s `fit`); `HostDisplayMode`; `GGUI_MOUNT_CANVAS_EXPORT`; the canvas vocabulary `CANVAS_CLASSES`, `CanvasClass` and `isCanvasClass` (moved here from `@ggui-ai/ui-gen`, which still exports them); and the inline card frame: `inlineCardRadius`, `cardFitRule`, `FRAMED_CARD_GROUND`, `recoverRadiusBucket`, `RADIUS_LADDERS`, `INLINE_CARD_RADIUS_PX` and `INLINE_CARD_RING`.\n- Added, in `@ggui-ai/mcp-server-handlers` (#1892): when enriching a committed render's `_meta[\"ai.ggui/render\"]` slice fails (reading the session back, presigning private gadget bundles, the inline projection, or reading the app record behind the public env and display mode), the server now logs `render_slice_enrichment_failed` with the render's `sessionId`, `appId` and the error. The render still succeeds with a minimal slice, as before; before, the failure was swallowed, so a card that could not mount inline, or lost its public env, left no trace. The name is in `CODE_DELIVERY_EVENTS.renderSliceEnrichmentFailed`.\n- Changed, in `@ggui-ai/mcp-server` (#1333): every tool's advertised `outputSchema` in `tools/list` is now OPEN at every depth (`additionalProperties: {}`), so a host that cached a schema keeps accepting results after a later release adds an optional output member; the server still strips what its own shape does not name before a result reaches the wire. `openOutputObject(toolName, shape)` (exported) derives the advertised object and throws at server build if any closed object remains. SPEC §7.9 \"Advertised output objects\" carries the MUST. One release of the old two-step remains: the first member added after this release still waits one release, exactly once.\n- Added, in `@ggui-ai/protocol-conformance` (#1333): the `output-schema` pure-function catalog grades a deployment's served `tools/list` document — `toolsList` on `runConformance()`, `--tools-list <file.json>` on the CLI — one row per tool that advertises an `outputSchema`, failing with the path of every closed object.\n- Added, in `@ggui-ai/protocol` (#1291): `MODEL_WIRE_ALIASES` — the typed declaration of a wire spelling that is not a registry row's own wire id (a vendor `alias` or a dated `snapshot`), so the allowlist and the registry are one list, pinned both ways. Rows added: `anthropic/claude-opus-4-8` (legacy), `openai/gpt-5.5`, `openai/gpt-5.5-pro`. Declared: `gpt-5.6` → `gpt-5.6-sol`; the default snapshots of gpt-5.5, gpt-5.5-pro, gpt-5.4, gpt-5.4-mini and gpt-5.4-nano.\n- Removed, in `@ggui-ai/protocol` (#1291): from `MODELS.google` (and so from `KnownModelOf<'google'>`), `gemini-3.1-flash-lite-preview` — shut down by Google; replacement `gemini-3.1-flash-lite` — and `gemini-flash-latest` — a floating alias Google hot-swaps with every release; replacement: none, pin a versioned id.\n- Changed, in `@ggui-ai/protocol` (#1889): a registry row can say what ANSWERS it — `ModelConfig.answeredBy` and `answeringModelOf(id)`. `gemini/gemini-3.5-flash` (answered by `gemini-3.6-flash`) and `gemini/gemini-3.7-flash` (answered by `gemini-3.8-flash`) move to `legacy` and are priced as the model that answers, per Google's 2026-10-08 changelog; both ids keep their rows, so a persisted id is never refused.\n- Fixed, in `@ggui-ai/mcp-apps-react-native` (#1880): the package now publishes compiled JavaScript (`dist/`, ESM and CommonJS, with `.d.ts` types) instead of its TypeScript source, like every other `@ggui-ai/*` package. A React Native app bundles it with a stock Metro and no `metro.config.js`. From 0.24.0 to 0.28.0, `expo export` failed with `Unable to resolve module ./McpAppIframe.js`, because the source imports its own files as `./x.js` and Metro does not map that to `./x.ts`. If you added a Metro `resolveRequest` rule for this, you can remove it once you upgrade. The exports and their types are unchanged.\n- Fixed, in `@ggui-ai/mcp-server` (#1865): `POST /oauth/authorize` records the authorize on the client (`lastAuthorizeAt`, `lastResource`) before it mints a code, and fails closed if the storage cannot: the user gets an error page and no code is issued. Before, the record was written after the code and any failure was swallowed, so a shared store that ties a client's lifetime to that record could issue a working code for a client that then lapsed. A storage failure anywhere on the OAuth routes now answers a defined error (the generic error page, or `server_error` JSON on `/oauth/register` and `/oauth/token`) and logs `oauth_route_failed`. Before, the request was left without a response, and the error never reached the browser or the log.\n- Added, in `@ggui-ai/mcp-server` (#1864): the OAuth authorization server's storage seam is exported from the package entry: `OAuthConfig`, `OAuthStorage`, `AuthCodeRecord` and `ClientRecord`, with `InMemoryOAuthStorage` as the reference implementation. `createGguiServer({ oauth: { storage } })` already took a store; now one can be written against the published types. The default in-memory store is correct for a single replica only: one sign-in's requests (the client registering and exchanging the code, the user's browser approving) can reach different replicas, so a multi-replica deployment needs a shared store, and session affinity cannot help.\n- Added, in `@ggui-ai/ui-gen` (#1839): a failed `warmupRuntimeRenderWorker` (from `@ggui-ai/ui-gen/harness/check/runtime-render`) now says why it failed in a required `kind`. `incomplete` means the check ran out of its bound, as a cold machine's first spawn can, which does not show that the worker is broken. `blocking` means the check finished and reported a failed or unverified issue. `spawn` means the worker could not be started at all. The kinds are exported as `RUNTIME_RENDER_WARMUP_FAILURE_KINDS`, with the `RuntimeRenderWarmupFailureKind` and `RuntimeRenderWorkerWarmup` types. Branch on `kind`; `reason` stays prose for a human. What the warm-up runs, and the in-loop render probe, are unchanged.\n- Added, in `@ggui-ai/mcp-server` (#1843): `currentRequestUserAgent()` returns the `User-Agent` header of the request in progress, or undefined outside a request or when the client sent none, so an embedder's own logger can mark a line with the client that caused it. The request context records the header for every request. It is client-set and untrusted: use it for attribution only, never for auth, trust or routing. Nothing the server does reads it.\n- Added, in `@ggui-ai/agent-server` (#1861): `McpServerConfig.headers`, extra HTTP headers for one MCP server, such as a gateway's own auth header or a tracing header. The library sends them on every request it makes to that server (`initialize`, `tools/list`, `tools/call`, `resources/read`), and passes them to the adapter (`ResolvedMcpServer.headers`), which sends them too. Headers the library sets itself (`Authorization`, `Content-Type`, `Accept`, `Mcp-Session-Id`, `Mcp-Protocol-Version`, `RESERVED_MCP_HEADER_NAMES`) are refused at startup in any letter case, so an extra header can never displace the bearer. `parseMcpHeadersEnv` reads them from an environment variable holding a JSON object, failing at startup with the variable named. The three agent samples read `GGUI_MCP_HEADERS` for the ggui server and `GGUI_<NAME>_MCP_HEADERS` for a discovered one.\n- Added, in `@ggui-ai/design` (#1847): a `tint` prop on `Box` and `Card` fills the element with one of eight categorical colours, `tint-1` … `tint-8` (exported as `TINT_STEPS`), for data categories such as series, lanes, kinds or element classes. A tint carries no meaning beyond \"category k\": it is never a state colour or decoration, and the category should also be said in words, on the element or in a legend. A tinted element reads the theme's text colour, and a tint replaces the element's `surface`; on `Box` an `assetColor` still wins. Every theme carries eight values per scheme as `--ggui-color-tint-1` … `--ggui-color-tint-8`, derived from its own surfaces and never authored: one lightness and chroma at hues 42 + 45k, moved only as far as needed for the theme's text to read at 4.5:1 on every tint and for each tint to stay apart from the container, from the other tints and from every state container. A saved theme that predates the tints gets them derived from its own values.\n- Fixed, in `@ggui-ai/design` (#1848): a theme's success, warning and info containers (and any other state container the theme leaves unset) now sit on the theme's own side. On a dark page they are dark chips with light text, where before every shipped dark scheme derived light chips, because those schemes write their status ramps with stop 100 dark and 800 light and the derivation assumed the opposite. A theme whose container is dark under its \"light\" name gets the same treatment, which also lifts premium-neon-noir light's status text from 1.7:1 to above 7:1. Containers a theme states itself are unchanged, and a saved theme completes the same way.\n- Added, in `@ggui-ai/ui-gen` (#1271): `anthropicToolCall` (from `@ggui-ai/ui-gen/providers`) sends one Anthropic Messages request that asks for one tool call and returns that tool's input, with the response's token usage when it reported one. A model that accepts a forced `tool_choice` gets one. A model that refuses it (the always-thinking family) gets `auto` with at most one call, the tool named in the system prompt, and 16,000 tokens of thinking headroom added to your answer budget, because its thinking counts against `max_tokens`. Every way the turn ends without the tool input throws `AnthropicStructuredCallError` with a `kind`: `max_tokens`, `no_tool_call`, `refusal`, `http` or `malformed` (listed in `ANTHROPIC_STRUCTURED_CALL_FAILURE_KINDS`). A network failure, an abort and a body that is not JSON keep their own error types. `@ggui-ai/mcp-server`'s structured negotiator call now goes through it and re-exports the error class. To recognise the error, use `isAnthropicStructuredCallError(err)`, exported from both packages: it matches by shape, so it still holds when two copies of `@ggui-ai/ui-gen` are installed, where `instanceof` would not.\n- **Security fix (`@ggui-ai/mcp-server-handlers`, `@ggui-ai/mcp-server`):** within one app, a caller that carries an end-user identity now reaches only its own sessions (#1483). `ggui_update`, `ggui_amend`, `ggui_emit`, `ggui_runtime_pull`, `ggui_runtime_submit_action` and `ggui_runtime_sync_context` checked only that a session belonged to the caller's app. So an end-user who held another end-user's session id, on a server where one app's end-users each sign in with their own credential, could change or stream into that user's card, read its events, dispatch gestures into it and overwrite its context. Each now answers another end-user's session exactly as a missing one, before any effect, as `ggui_consume`, `ggui_get_session` and `ggui_get_render_source` already did. On the live channel, a bearer subscribe that carries an end-user identity is refused `SESSION_NOT_FOUND` for another end-user's session, before it registers; a `wsToken` subscribe is unchanged. A caller with no end-user identity (an app or builder credential) is admitted as before. Each refusal logs one line with ids only, never a user id: `runtime_cross_subject_refused` on the tools, `render_channel_subject_refused` on the channel. If your server authenticates an app's end-users with their own credentials, upgrade. The conformance kit does not grade this; the packages' own tests do, including one that covers every tool taking a `sessionId`.\n- Changed: `renderReadAllowed` (`@ggui-ai/mcp-server-handlers/renders`) takes any context with an `appId` and an optional `userId`, not only a full `HandlerContext`.\n- Fixed, in `@ggui-ai/protocol` (#847): a NodeNext consumer with `skipLibCheck: false` no longer sees errors on this package's declarations. `McpUiDisplayMode` is declared by the package (pinned equal to `@modelcontextprotocol/ext-apps`' type at every bump) and ext-apps is no longer a dependency of `@ggui-ai/protocol`, so its extensionless `.d.ts` specifiers (ext-apps 1.x) never enter a protocol-only program. `@ggui-ai/mcp-server` and `@ggui-ai/iframe-runtime` still depend on ext-apps; the `skipLibCheck` note stays for them.\n- Removed, in `@ggui-ai/protocol` (#847): the re-exports `McpUiHostContext` and `McpUiHostCapabilities` — import them from `@modelcontextprotocol/ext-apps`, where every current consumer already does. `McpUiDisplayMode` stays exported.\n\n## 2026-10-12 — v0.28.0: the open-source default is Claude Haiku 5.5, one zero-config route table for every first-run path, and the server keeps idle connections open past a load balancer's timeout\n\n- Changed, in `@ggui-ai/ui-gen` and `@ggui-ai/negotiator` (#1804): the evaluators' fallback Claude judge (`runEvaluation`, `runLLMEvaluation`, the visual evaluator), used only when you pass no `model`, is now `claude-haiku-5-5` instead of `claude-haiku-4-5-20251001`. The negotiator's rerank-eval and synth-bench CLIs default to `claude-haiku-5-5` too. A generation's own in-loop evaluation is unchanged, because it judges with the generation's evaluation agent, which defaults to the coding model. Pass `model` to keep the previous judge. CLI readings made before and after the switch aren't comparable.\n- Changed, in `@ggui-ai/ui-gen` (#1743): when you pass no `model`, `createUiGenerator` now generates with `anthropic/claude-haiku-5-5` instead of `anthropic/claude-haiku-4-5`. Measured before the switch (same-session A/B on the benchmark's prompts, judge panel fixed): +3.07 panel points (one-sided 95% lower bound +0.88), no rise in the share of generations taking six or more turns, the same contract-behaviour failures, at about 0.18× the cost per generation. On Bedrock it maps to the `us.anthropic.claude-haiku-5-5` inference profile. Pass `model` explicitly to keep the previous model.\n- Changed, in `@ggui-ai/protocol` (#1743): `DEFAULT_MODEL` is `anthropic/claude-haiku-5-5` (was `anthropic/claude-haiku-4-5`), on the same measurement; `MODEL_LINEUP` — the models a picker shows first — gains Claude Haiku 5.5 and drops Claude Haiku 4.5, which becomes `legacy` (still listed under \"See all models\", still routable). Claude Sonnet 5.5, GPT-6.1 Sol and Gemini 3.8 Flash stay off the lineup (Sonnet 5.5 joins it in the next release, #1803). The MCP wire is unchanged.\n- Added, in `@ggui-ai/protocol` (#1743): the OpenRouter route `anthropic/claude-haiku-5.5` joins `MODELS.openrouter` (the autocomplete list; any `<author>/<model>` string was already accepted by shape), after the same OpenRouter tool-calling smoke that gated the other listed ids passed 3/3. The CLI's zero-config OpenRouter route moves to it separately. Also recorded (#1776): the Anthropic sampling-parameter rule deliberately does not match OpenRouter's dotted model ids, because the OpenRouter route accepted a request with `temperature` set; sending a temperature through OpenRouter is not a reproducibility guarantee.\n- Added, in `@ggui-ai/protocol` (#1793): `ZERO_CONFIG_ROUTE_BY_PROVIDER` — the route a first-run path generates with when one provider key is present and no model is named, per provider (Anthropic, OpenAI, Google, OpenRouter; Bedrock credentials come from the AWS SDK chain, outside a key probe). The Anthropic row is derived from `DEFAULT_MODEL` in code; every row is a listed model, and every non-OpenRouter row an active registry entry. `ggui serve` reads this table instead of keeping its own, so a default switch reaches the CLI's zero-config path in the same release. Values: `anthropic:claude-haiku-5-5`, `openai:gpt-5.6-luna`, `google:gemini-3.5-flash-lite`, `openrouter:anthropic/claude-haiku-5.5`.\n- Added, in `@ggui-ai/cli` (#1167): `ggui serve --no-console` (or `GGUI_NO_CONSOLE=1`) serves no operator console, for a server a tool launches only as an MCP endpoint. No console routes are mounted and no admin token is minted, so the boot output carries no `admin token →` line and no `ADMIN_TOKEN` line. A key-less boot names only the env vars to export, and a key-less render returns `NO_CREDENTIALS` instead of the Connect-Claude card. `--mcp-only` is unchanged and still serves the console.\n- Changed, in `@ggui-ai/cli` (#1792, #1793): `ggui serve` with one provider key and no model named generates with `claude-haiku-5-5` on Anthropic (was `claude-haiku-4-5-20251001`) and `anthropic/claude-haiku-5.5` on OpenRouter (was `anthropic/claude-haiku-4.5`); OpenAI and Google are unchanged. The CLI now reads these routes from `ZERO_CONFIG_ROUTE_BY_PROVIDER` instead of keeping its own table, and the hint it prints when `ggui.json#generation.model` is unset names the same routes. Pin `ggui.json#generation.model` if you need the previous model.\n- Added, in `@ggui-ai/protocol` (#1796): the Bedrock routes for Claude Haiku 5.5 — `bedrock:anthropic.claude-haiku-5-5` (the Messages-API id), `bedrock:us.anthropic.claude-haiku-5-5` and `bedrock:global.anthropic.claude-haiku-5-5` — join `MODELS.bedrock`, each listed in the region and priced in the vendored table before registration. Registered is not callable: a Bedrock deployment invokes them only once its own AWS account has accepted the model's Bedrock agreement (`get-foundation-model-availability` → `agreementAvailability: AVAILABLE`); before that every invoke is refused with `AccessDeniedException`. `eu.`/`apac.` profiles and Sonnet 5.5's Bedrock ids are not listed yet (priced, not read). On a Bedrock deployment with the agreement in place, the open-source default (Haiku 5.5) is a listed route in every form the adapter uses.\n- Clarified, in `@ggui-ai/protocol` (#1801): `MODELS.bedrock`'s two id families name two endpoints. A region-less `anthropic.*` id is served only by Bedrock's Messages-API endpoint; the same string sent to bedrock-runtime (`InvokeModel`/`Converse`) is refused at invoke as inference-profile-only. Pick the endpoint by the id's shape, as `@ggui-ai/ui-gen`'s Bedrock adapter does; a caller wiring its own bedrock-runtime client takes a region-prefixed profile id, never a bare one. A test now pins every bare id in the list to the vendor's published Messages-API table. Comments and a test only.\n- Fixed, in `@ggui-ai/ui-gen` (#1790): the `state.payload.covers_submit` self-check no longer reports a submit payload's keys as missing when the form's state holds them in a shape the check couldn't read. It now also reads state initialised from a named constant (`useState(INITIAL)` with `const INITIAL = { … }` in the file), a lazy initializer (`useState(() => ({ … }))`) and `useReducer(reducer, { … })`, taking each object's top-level keys. Before, a form written that way failed the check on every turn with nothing to fix, and a generation could run to its turn cap.\n- Fixed, in `@ggui-ai/ui-gen` (#1791): the same `state.payload.covers_submit` self-check also counts a form's `contextSpec` slots. The boilerplate binds each slot as `const [x, setX] = useGguiContext<T>('x')`, and the system prompt tells the model the runtime owns that state and not to write `useState` for it, so a form built on context slots failed the check on every turn with nothing it was allowed to change. Both the binding's name and the slot's name count. The check only gains keys, so a payload key that nothing binds is still reported missing.\n- Added, in `@ggui-ai/ui-gen` (#1791): a same-failure guard on the coding loop. When one self-check has been reported by five failed writes since the last write that passed, the run ends, whatever the patch and whatever else failed in between; the third report adds one nudge to the tool result. Before, a model that changed some other line every turn while the same check kept failing never read as stuck, and the run went to its turn cap. The guard exits the way the turn cap does, so the result ships what the cap would have shipped (the last build that passed self-check, else the last that built), and `GenerationResult.sameFailureBreak` (`{ signature, repeats }`, exported as `SameFailureBreak`) says the guard ended it; the key is absent otherwise. `@ggui-ai/shared`'s `GenerationResultDisplay` gains the same optional `sameFailureBreak`.\n- Changed, in `@ggui-ai/ui-gen` (#1761): the `state.merge.no_hardcoded_entities` repair line names the contract props the flagged data comes from (for example `props.tasks / props.columns`) and says to read an absent value with `?? []`, never a literal default. Before, it said to seed state from `props.{entityProp}`, a placeholder that was never filled in. What the check fires on is unchanged.\n- SPEC erratum (§7.9.1, §12.2.3), with no wire change (#1554): the one-code rule for an unusable GguiSession (`session_not_found` / `SESSION_NOT_FOUND`, causes never told apart) is stated for `tools/call` and for live-channel frames AFTER a subscribe. The subscribe itself has always disclosed whether an id exists — an unknown id is provisioned and acks — and answered `APP_MISMATCH` for a GguiSession bound to another app, the behaviour the conformance kit grades (`subscribe-app-scope/app-mismatch`, `absent-appid-defaults`); the old text claimed the one code there too. §12.2.3 now says what a subscribe discloses and why that is not an enumeration surface: a `sessionId` is server-minted, never a subscriber's choice.\n- Changed, in `@ggui-ai/iframe-runtime` (#1734): a card parked on its bridge rung — every token rung refused or unreachable, as on a CSP-jailed host — now checks its live credential on every bridge pull and asks `ggui_runtime_refresh_ws_token` again once the credential has expired, at most once per 180 s counted from its last attempt and whatever that attempt answered; a bridge refresh re-sends on a relay error as the boot refresh does. Before, a parked card asked at most twice per mount, so a host whose relay stops pulling after repeated failures (`@guuey/mcp-apps-host` 0.31's pull circuit, which reopens only on a refresh the session grants) left the card dark until re-mount, and a refresh that failed inside the outage was never asked again.\n- Added, in `@ggui-ai/protocol` (#1734): `DEFAULT_WS_TOKEN_TTL_SEC` (180) on `@ggui-ai/protocol/transport/websocket` — the default lifetime of a `wsToken`, defined once for the server's minter (`@ggui-ai/mcp-server-core` re-exports it under the same name) and the card's bridge-rung attempt clock.\n- Changed, in `@ggui-ai/protocol` (#1735): the `ggui_runtime_pull` `wait` field's description, which ships on `tools/list`, now states the relaying host's second obligation beside the clamp — a host MUST NOT re-issue a held pull on its own timeout, because the view's backoff lives above the relay and a relay-side retry multiplies holds at the server while the view sees one failure. The SPEC's bridge rules carry the same clause with the observable violation. A description only: no wire shape, code or member moves.\n- Added, in `@ggui-ai/mcp-server` (#1553): the render read door (`resources/read` of `ui://ggui/render/*`) logs one `render_resource_read` info line per allowed read — door, app, auth source, and whether app trust admitted the caller over a subject-bound row; never the session or the subject — so the `render_read_app_trust_over_subject` admission count has the read traffic beside it. Denied reads keep their `render_resource_read_denied` line and log no read line.\n- Changed, in `@ggui-ai/mcp-server` (#1828): the MCP route answers a `resources/read` with `application/json` instead of a `text/event-stream` stream; every other method, `tools/call` included, keeps the stream. Streamable HTTP lets a server answer either way and requires clients to accept both, so an MCP SDK client sees no difference; a client that parses the raw HTTP response must handle both shapes. As JSON, a `ui://` shell (about 1 MB) can be compressed by a proxy in front of the server.\n- Changed, in `@ggui-ai/protocol` (#1736): `handshakeOutputSchema` — and `GguiHandshakeOutput` derived from it — is now exactly what `ggui_handshake` emits: `propsSchema`, `propsSchemaHash` and `propsSchemaProfile` join it (they have been on the wire since the 2026-08-19 schema-precise render), and `reason` and `nextStep.description` leave it (the server has not emitted them since the 2026-05-13 output trim). The server's handler now derives its output shape from the protocol schema, so the two cannot drift apart again; `tools/list` is byte-identical before and after. Pre-1.0 minor (VERSION-POLICY §3.7): a consumer typed on `.reason` or `nextStep.description` sees a compile error at its next bump — the wire never carried either. The `@ggui-ai/e2e-mcp-host-simulator` `HandshakeOutput` mirror follows the wire; a test that read `openRender(...).contractHash` still can.\n- Changed, in `@ggui-ai/negotiator` (#1795): the blueprint matcher's judge prompt states that a surface's UI shape includes its interaction model, so a filter-as-you-type surface (context only, nothing to press) no longer matches a request for one that submits an action. The rule that differing fields, slots or actions don't block a match is unchanged. On the matcher's 28 labelled pairs at production thresholds, four judge models read recall 12/13 with 0/11 false matches.\n- Changed, in `@ggui-ai/mcp-server-handlers` (#1795): `DEFAULT_PROBE_JUDGE_MODEL`, the Anthropic judge the cache-reuse and match-precision probes use when you name no model, is now `claude-haiku-5-5` instead of `claude-haiku-4-5`. It keeps the forced-tool path and the probes' 512-token answer budget. Pass a model to keep the previous judge.\n- Changed, in `@ggui-ai/mcp-server` (#1772): `listen()` now sets `keepAliveTimeout` 65 s and `headersTimeout` 66 s on the `node:http` server it returns (Node's defaults are 5 s and 60 s), exported as `DEFAULT_KEEP_ALIVE_TIMEOUT_MS` / `DEFAULT_HEADERS_TIMEOUT_MS`. Behind a proxy that pools upstream connections — ingress-nginx keeps them 60 s by default — Node's 5 s idle close let the proxy reuse a connection the server had just closed, and a `tools/call` on it came back as a 502. The upstream's idle timeout now exceeds the proxy's. An embedder that mounts the Express app under its own listener sets its own values; one that calls `listen()` inherits these.\n\n## 2026-10-08 — v0.27.0: the Claude SDK adapter offers only the MCP servers you configure — pass `builtinTools` if you used the CLI built-ins — and a kit control keeps the border the kit draws\n\n- Changed, in `@ggui-ai/ui-gen` (#1714; replacement: `builtinTools`): `ClaudeSdkAdapter` (from `@ggui-ai/ui-gen/adapters`) and `runEvaluationLoop` (from `@ggui-ai/ui-gen/evaluation`) no longer give the model the Claude Code CLI's built-in tools, and no longer load your own Claude configuration. Before, a session they ran offered `Bash`, `Edit`, `Read`, `Write`, `WebFetch`, `Task` and the rest with permission checks bypassed, and **your own `~/.claude` plugins and MCP servers were loaded into generation sessions too** — `allowedTools` only approved the names it listed; it never narrowed the set. Both now pass `tools: []` and `settingSources: []` and no permission bypass: the model sees the MCP servers you configure and nothing else. When you give no `allowedTools`, every tool of those servers is approved (`mcp__<server>`). A built-in is an explicit opt-in, `builtinTools: ['Write']`, which offers and approves exactly the names you list. If you relied on the built-ins, pass them there. With no `Write`, both read a component's source from the bridged `compile_component` call by one rule, and so does `extractSourceCodeFromMessage` (from `@ggui-ai/ui-gen/evaluation`). `runEvaluationLoop` with more than one round now throws when `generatorOptions` gives it no MCP server and no built-in, because its fix round would have nothing that can return code. The ggui harness never used these paths, so generation through `createUiGenerator` is unchanged.\n- Changed, in `@ggui-ai/ui-gen` (#1697): a kit control keeps the boundary the kit draws. The system prompt has a new mandatory rule: never set an inline `border`, `borderColor`, `borderWidth` or `borderStyle` on a kit control (`Button`, `Input`, `Select`, `TextArea`, `Checkbox`, `RadioGroup`, `Toggle`, `SearchField`). An outline chip is `<Button variant=\"outline\">` as it comes, and a control-like edge on the card's own element uses `var(--ggui-color-controlOutline)`. The token table keeps `primary-200` / `primary-300` for dividers and the edges of non-interactive boxes. `controlOutline` and `controlAccentOutline` are now taught tokens. A new tier-0 check, `control-inline-border` (critical, constrained mode only), flags an inline border key on a kit control, in the tag's own style or in a same-file style object it names; a radius is not flagged. Generated chips had set `primary-200` as their border, about 1.1:1 against their ground; the kit's control edge clears 3:1 in every theme. The prompt digests change.\n- Changed, in `@ggui-ai/design` (#1567): low-emphasis text reads as text in every shipped theme. The `subtle` tone now resolves to the theme's muted ink (`onSunken`), like `muted`; ten components that painted hint text with `neutral-500` (Autocomplete, Breadcrumb, FormField, ChatWindow, CommandPalette, CommentThread, DataTable, FileUploader, Footer, NotificationCenter) use the tone, so a theme shows one muted grey; NotificationCenter's unread row sits on the sunken ground and its timestamps use the tone; and the default theme's muted ink is darker in light (`#596273`) and lighter in dark (`#adb9ca`). Every shipped theme's muted ink now clears 4.5:1 on both the container and the sunken ground. In gguiDark, hint text goes from 4.36:1 to 5.80:1 on sunken; gguiLight is unchanged. The tone keeps its name, so code that asks for `subtle` needs no change.\n- Changed, in `@ggui-ai/mcp-server-core` and `@ggui-ai/mcp-server-handlers` (#1339): the host-session pair a `ggui_render` request carries on `_meta[\"ai.ggui/host-session\"]` is now set once by the first commit that carries it — fill-absent-never-overwrite, the rule the row's subject already follows — so a session row born before the render (a hosted server creates it at the handshake's provisional preview) still takes the pair; the in-memory and sqlite stores set it on their replace path, the conformance kit pins it on every store, and a later commit with a different pair is kept as it was and named once in the server's log (`host_session_conflict`, ids only). A slice on the `ggui_handshake` request is ignored, and the extension doc now says so. If you implement a session store yourself, the conformance kit now checks this on it: the pair is set on the store's replace path when absent, and never overwritten.\n- Changed, in `@ggui-ai/ui-gen` (#1711): the visual judge is no longer asked `state.feedback` on a capture with no post-action state. Its own text makes it not evaluated then, and a judged frame is one capture taken before any action. The row now reads `n/a` (\"not evaluated: no post-action capture\") and stays in the block's verdicts, so it is counted as considered. With a post-action capture (`postActionCapture`), the judge is asked as for any row. `REQUIRES_POST_ACTION_CAPTURE` names the rows this applies to. Report-only; no score changes.\n\n## 2026-10-02 — v0.26.0: the server refuses a spent one-shot, the SMTP sender moves to nodemailer 10 — upgrade if you embed it — and unused exports go\n\n- Removed exports (a pre-1.0 minor, VERSION-POLICY §3.7). Before 1.0, a minor release may remove an export without a deprecation window. A caret range such as `^0.25.0` never picks up a new minor, so nothing changes for you until you raise the range. Every export 0.25.0 had that 0.26.0 does not:\n  - `@ggui-ai/negotiator`: `hashContract`, `buildVariant`;\n  - `@ggui-ai/mcp-server-core`: `mintSessionToken`, `DEFAULT_SESSION_TOKEN_TTL_SEC`, `nextSpentOneShotsRecord`, and the variant selector (`selectVariantWithLlm`, `encodeSelectedReason`, `preFilterCandidates`, `computeVariantSelectionCacheKey`, `VariantSelectionContext`, `VariantSelectionDecision`, `VariantSelectionCache`, `VariantSelectionCacheEntry`, `VariantSelectionPickFn`, `VariantSelectionResult`, `VariantSelectorWithLlmOptions`, `DEFAULT_VARIANT_SELECTION_CONFIDENCE_THRESHOLD`, `DEFAULT_VARIANT_SELECTION_CACHE_TTL_SEC`, `DEFAULT_VARIANT_SELECTION_SHORTLIST_SIZE`, `BlueprintSelector`, `BlueprintSelectorContext`, `createDeterministicBlueprintSelector`, and on `/in-memory` `InMemoryVariantSelectionCache`, `InMemoryVariantSelectionCacheOptions`);\n  - `@ggui-ai/mcp-server-handlers`: `GenerationDeps.resolveLlmCaller`, `HandshakeNegotiator.selectVariant`, `REFRESH_WINDOW_CLOSED` in `GguiRefreshWsTokenOutput`'s `code`, and `validatorScore` on `ggui_ops_generate_blueprint`'s output;\n  - `@ggui-ai/mcp-server`: `GguiSessionChannelBootstrap.issueSessionToken`, `createGguiServer`'s `blueprintSelector` option and `GguiServer.blueprintSelector`;\n  - `@ggui-ai/protocol`: `validatorScore` on `opsGenerateBlueprintOutputSchema`.\n\n  Each is described, with its replacement where it has one, in its own line below. `AckPayload.sessionToken` and `TokenKind`'s `'session'` stay one more release.\n\n- Added, in `@ggui-ai/iframe-runtime` (#1679): a card whose component throws past the error boundary's retry is now reported to the server — one `ggui_runtime_report_render_failure` per session, carrying the phase (`mount` or `update`), the thrown value's class name and the boundary's catch count, with the view proof on the call. Nothing the visitor sees changes, and a report that is refused or lost is never resent. The tool itself has been served since #1609; this is the first runtime that sends it. In `@ggui-ai/protocol`, `renderFailureErrorName` now recognises an `Error` from another realm by its brand, so a card module evaluated in the document's realm reports its class name rather than `Error`; the report's vocabulary gains its own pure-const subpath, `@ggui-ai/protocol/render-failure`, for runtimes that must not bundle the schema modules (the root re-exports the same names).\n- Fixed, in `@ggui-ai/mcp-server-handlers` and `@ggui-ai/mcp-server-core` (#1424): the server now refuses a second gesture on a spent one-shot action. `ggui_runtime_submit_action` claims the spend before it appends the gesture, and a later dispatch of the same declared `oneShot` on the same card under a different `actionId` answers `{ok:false, code:\"CONTRACT_VIOLATION\"}` with one violation at `actionSpec.<name>.oneShot` and reaches no agent. Before, the spend was recorded after the append and never changed the answer, so a client that was not the ggui runtime could fire a one-shot twice. The same `actionId` is still a retry; a holder that claimed and never delivered is taken over by the next gesture, which is delivered once. The session store's `recordSpentOneShot` now answers a `SpentOneShotClaim` (`recorded`, `already-spent` with the holder and its delivery mark, `superseded`, `released`). A store that still answers nothing keeps the earlier behaviour, and the conformance kit names it as a skip. The read view `spentOneShots` is unchanged.\n- Changed (a pre-1.0 minor, VERSION-POLICY §3.7), in `@ggui-ai/mcp-server-core` (#1424): `nextSpentOneShotsRecord` is removed. Its replacement is `claimSpentOneShot` over a `SpentOneShotsLedger`, which claims the spend instead of recording it after the fact. A custom session store that built its record with the old helper moves to the claim; a store that never recorded spends (its `recordSpentOneShot` is optional) changes nothing.\n- Removed (a pre-1.0 minor, VERSION-POLICY §3.7), in `@ggui-ai/negotiator` (#1334): `hashContract` and `buildVariant`. This ships in 0.26.0, a minor, and must not ride a 0.25.x patch: a caret range on 0.25.x never picks it up. Neither had a caller. `hashContract` hashed the intent alone and was never the cache key: every `contractHash` on the wire is `blueprintKey(contract)`. Replacements: for `hashContract`, `blueprintKey` from `@ggui-ai/protocol/blueprint-key`; for `buildVariant`, none (`variantKey`, from the same subpath, is the key of a blueprint's variance and is not the same thing). `invokeRequestSchema` (`@ggui-ai/protocol`) stays and now names who speaks it: the host helpers' `useInvoke` sends that body, and its test parses the body with the schema.\n- Fixed, in `@ggui-ai/mcp-server-handlers` and `@ggui-ai/protocol` (#1339): `ggui_render` now captures the host's conversation-grouping slice, `_meta[\"ai.ggui/host-session\"]` (`hostName` + `hostSessionId`), on the session it creates, so `ggui_list_sessions` filtered by that pair returns the session. The slice, the store field and the list filter were documented and shipped, but no render passed the slice to the store, so the filter matched nothing. The render request is the one that must carry the slice (the handshake creates no session). Each field is now bounded at 256 characters (`MCP_APP_HOST_SESSION_FIELD_MAX_LENGTH`, new, from `@ggui-ai/protocol/integrations/mcp-apps`): `parseMcpAppAiGguiHostSessionMeta` returns `MALFORMED_HOST_SESSION` for a longer field, and the server treats a malformed slice as absent, with one `host_session_malformed` log line; the render still succeeds. A deployment whose session store persists `hostSession` starts writing it on new rows.\n- Fixed, in `@ggui-ai/mcp-server-handlers` (#1428): when `ggui_handshake` proposes a saved interface found by similarity, it now also compares the members of each action both contracts declare that change what happens on a gesture: `oneShot`, `confirm` and `nextStep`. If the saved interface's action lacks one your draft declares (or names a different `nextStep`), the proposal carries a `COVERAGE_GAP` warning at `actionSpec.<name>.<member>`, so you can accept it knowing the card will not behave that way, or override. Before, only action names were compared: a saved card without `oneShot` was proposed for a draft that declared it, with no warning. Reuse stays the default; a proposal with such a gap now ranks below one without any gap. An exact-key match is unaffected (those members are part of the key).\n- Fixed, in `@ggui-ai/mcp-server-handlers` (#1429): a render whose generator returns no `contract` now commits the contract the handshake agreed, whole. Before, only `propsSpec` was kept: the session committed no `actionSpec`, `streamSpec` or `contextSpec`, so a declared `oneShot` was not recorded as spent and `ggui_update` / `ggui_amend` validated against nothing. A returned contract is still taken as given: it is complete, not a patch over the agreed one, and `UIGenerationResponse.contract` (`@ggui-ai/protocol`) now says so. When a returned contract leaves out a spec or an entry the agreed contract declares, the server logs one `generator_contract_narrower` line naming what was dropped. The generator shipped in `@ggui-ai/ui-gen` returns the agreed contract, so its renders do not change.\n- Fixed, in `@ggui-ai/mcp-server` (#1562): `nodemailer` moves from `^9.1.1` to `^10.0.13`. That clears two high-severity advisories (GHSA-prgh-xp8r-p3m5 and GHSA-v53p-9fqp-m79j: address parsing that a crafted address can make quadratic, a denial of service) and three moderate ones (GHSA-g57g-f23g-4646, GHSA-8vvx-rff5-p5rq, GHSA-6vj9-mwq6-2f5v). The built-in email login bounds the address it sends to, but `SmtpEmailSender` is exported, and an embedder passing its own addresses was exposed. nodemailer 10 needs Node.js 20 or later (this package already requires 22) and ships its own types, so `@types/nodemailer` is no longer a dependency.\n- Changed, in `@ggui-ai/ui-gen` (#1652): the runtime-render check looks for each action by walking the card's screens in a mount of its own, pressing each control once per screen. A wizard's last step, an Edit button that becomes Save, and a form behind a button are reached, so their actions are verified instead of reported as not dispatched. Screens are told apart by their controls (labels and disabled state), never by typed values or generated names, so Back and Next cannot loop. Nothing bounds it while it is on the first screen with controls left to press there; once a press leaves the first screen, it stops at 60 presses, 3 s per action, or the check's time budget. An action it cannot dispatch says how far the walk got (`RenderCheckIssue.diagnostics.walk`), not the first button on the page. The action check no longer presses the copy of the card that prop coverage, prop sensitivity and stream re-render read (it is still primed as before, and only the duplicate-label check still clicks there). Prop sensitivity therefore compares two unpressed renders, and can report a baked prop it used to miss on a card the old check pressed. Prop coverage counts a prop shown on any screen the walk reached.\n- Added, in `@ggui-ai/mcp-server-handlers` (#1553): `renderReadVerdict` (from `@ggui-ai/mcp-server-handlers/renders`) returns the render read gate's answer with the rung that decided it; `renderReadAllowed` is its boolean. The two doors that use the gate, the `ui://` render resource read and `ggui_runtime_refresh_ws_token`, now log one structured line, `render_read_app_trust_over_subject` `{door, appId, source}`, when they admit a caller with no end-user identity to a session that has one. That is the one case where this gate and the agent doors' visibility check disagree, logged so its frequency can be measured before the two are unified. No access changes.\n- Fixed, in `@ggui-ai/negotiator` (#1430): a repaired handshake draft keeps every member the agent declared on each entry it keeps under the same key, on every spec, not only on actions: a stream's `mode`, `replay` and `complete`, a prop's `default`, a context slot's `debounceMs`, a tool's `serverInfo`, `example` and `toolInfo.description`, and the rest. A tool the repair dropped from the agent's `agentCapabilities.tools` is put back whole and named `REPAIR_ENTRY_RESTORED` (severity `warn`), so an action's valid `nextStep` survives the repair. A draft entry the repair renamed, moved or removed is named once, `REPAIR_ENTRY_DROPPED`, on every spec. The repair's `reasoning` counts drops and restored tools apart.\n- Fixed, in `@ggui-ai/protocol` (#1518): when the self-contained shell (`gguiShellHtml`) cannot load its runtime bundle, it no longer leaves its loading mark animating. After it posts `ggui:bootstrap-failed` / `BUNDLE_FETCH_FAILED`, it removes the mark and paints a failure line, \"This view could not load\", with a Retry that reloads the frame. That is the wording the thin shell already uses. A host that ignores the envelope, such as a third-party MCP Apps host reading the shell through `resources/read`, now shows the failure instead of a spinner. Nothing is painted while the unhashed twin is still being tried.\n- Removed, in `@ggui-ai/mcp-server-handlers` (#1510, a wire removal under VERSION-POLICY §3.6): `ggui_runtime_refresh_ws_token`'s advertised output schema no longer names `REFRESH_WINDOW_CLOSED` in its `code` enum, and the exported `GguiRefreshWsTokenOutput` no longer types it. No server has sent it since v0.25.0 made the refresh an authorized re-mint (#1496). It stayed declared for that one release, so a client that cached the older schema accepted every answer, and such a client still does.\n- Added, in `@ggui-ai/ui-gen` (#1640): `judgedFrameTokens` (from `@ggui-ai/ui-gen/evaluation`) sums a judged frame's or a visual evaluation's spend over every leg the judge reports: the scoring calls' `inputTokens` / `outputTokens` and the report-only criteria call's `criteriaTokens`. A cost total built on it counts the criteria call, and a token leg added to the judge's result fails the package's build until the sum counts it. Additive; the judge's results are unchanged.\n- Added, in `@ggui-ai/protocol` (#1381): a `@ggui-ai/protocol/runtime-telemetry` subpath with the runtime telemetry vocabulary. `RUNTIME_TELEMETRY_KINDS` lists every kind a card's runtime emits, which of the two batches it rides, and the one `detail` shape it admits, so the emitter and a host that admits only a closed set import the same names. `CHANNEL_LOG_EVENTS` and `ChannelLogEvent` move here from `@ggui-ai/live-channel`, which re-exports them unchanged. The subpath has no runtime imports. `@ggui-ai/live-channel` now depends on `@ggui-ai/protocol`, where it had no dependencies before: installing it alone now installs protocol and its dependencies, while a bundle takes only this subpath's constants. The server still logs a kind it does not name and never refuses one.\n- `GguiServer.close()` in `@ggui-ai/mcp-server` is bounded (#1631). It stops accepting connections and closes idle ones at once, then lets active ones finish for up to `graceMs` (default `DEFAULT_CLOSE_GRACE_MS`, 5 s, now exported) before it ends every connection still open. So a client that never finishes its request, or never reads a response, can no longer hold a shutdown open. Pass `close({ graceMs: Infinity })` for the previous unbounded wait.\n- Added, in `@ggui-ai/ui-gen` (#1663): a report-only alignment measurement. Before each screenshot, the visual evaluator reads the card's left edges from the DOM (`EDGE_BLOCKS_EXPRESSION`, typed by `parseEdgeProbe`) and applies the alignment rule to them (`judgeEdges`), reporting every offset in px. In a criteria block, the `space.edge` row gains `instrument: { verdict, evidence }` beside the judge's verdict, which it does not change; a stored frame with no page to read says so. `CriterionVerdict.instrument` is optional, and nothing changes for a block without that row.\n- Added, in `@ggui-ai/ui-gen` (#1687): the visual judge's record says which model answered and what its criteria call returned. Each canvas's `judge` record gains `models: { requested, served }`; `served` lists the names the provider gave for the model that answered (Anthropic's `model`, Google's `modelVersion`), and is empty when none did. A criteria block gains `call`: whether the answer held a `criteria` array, how many entries it had, how many were kept, how many were dropped and why, how many named no row of the bank, and the call's stop reason. A row the judge left unanswered still reads `n/a`; the block now says whether the call came back empty, cut, or with entries the parser dropped. Both fields are optional and report-only.\n- `@ggui-ai/ui-gen`'s runtime-render check and render smoke test no longer mistake a component's own console output for their verdict. Each worker now writes its verdict on one marked line, and the host reads that line. Before, a generated component that logged even one line while it was checked turned its check into an `unverified` \"verdict was not valid JSON\".\n- Changed, in `@ggui-ai/mcp-server` (#1397): a `ggui_consume` result that drains a gesture no longer leads with a plain-text sentence telling the agent to repaint and then call `ggui_consume` again. The `nextStep` hint to `ggui_amend` stays in the structured result. Measured on two models: the sentence led one of them to answer an informational tap with a silent re-poll instead of a reply, and removing it kept every repaint.\n- Fixed, in `@ggui-ai/protocol` (#1637): the JSON Schema served on `tools/list` for `ggui_handshake`, `ggui_render` and `ggui_runtime_sync_context` is now one Google's Gemini API accepts as function parameters. The JSON-value definition's object arm no longer refers back to itself (its members are open, `additionalProperties: {}`, and checked in code). Before, Gemini refused the whole tool list. The values accepted are unchanged; an invalid value nested inside an object is now reported at the member that holds it.\n- Added, in `@ggui-ai/protocol` (#1175): the theme `GET` names a stored theme it cannot read. When the stored document fails the carry shape, the response is `theme: null` beside `uninterpretable: { issueCount, issues: [{ path, code }] }`, so it no longer reads like \"no theme\". Codes come from the protocol's own closed `THEME_ISSUE_CODES`, mapped by `describeUninterpretableTheme`. No stored value is carried, and a bare `theme: null` still means no theme. A reader that ignores the new member sees exactly the previous response.\n- A copied blueprint row now says it is a copy (#1570). `ggui_ops_register_blueprint` takes an optional `clonedFrom`, the id of the row whose bytes it registers, and so does `createRegisterGeneratedBlueprint` (`@ggui-ai/mcp-server-handlers`, the in-process entry), which parses the same input. The durable row carries it, so `ggui_ops_list_blueprints` lists it. Over MCP a server from before this strips the member, so a copy sent to it lands unmarked; check its `tools/list` before relying on the mark. An empty id, the row's own id, and an id that is not a row of the registering app are refused before anything is persisted (`ClonedFromRefusedError`), with the same answer for another app's row as for a missing one. Nothing that serves reads the field.\n- A card's runtime telemetry travels in two batches (#1383), never mixed in one `ggui_runtime_telemetry` call. The **health** batch carries which rung the card booted on (`boot.path`, `boot.static_only_no_bridge`), its connection status (`status.<connecting|connected|disconnected|reconnecting>`), `subscribe.resolved`, `doorbell.ring` and the live channel's transport events (the `channel_*` names). Each health kind admits one `detail` shape (none, the card's own render id, or a JSON object of booleans), and a detail outside it is left off the event. The **diagnostic** batch carries the gesture trail and the ladder's finer story, and no longer carries free text: `gesture.dispatch` keeps `{toolName}`, `gesture.result` keeps `{ok}` and the JSON-RPC error code (never the message), `gesture.dropped_superseded` carries no detail, and `subscribe.resolved` is `{hasAck}` only. The health batch flushes first, and the two batches have separate per-session flush caps (8 and 4). `@ggui-ai/iframe-runtime` exports the one table of kinds as `RUNTIME_TELEMETRY_KINDS` (with `RuntimeTelemetryKind`), and `@ggui-ai/live-channel` exports its transports' log events as `CHANNEL_LOG_EVENTS` (with `ChannelLogEvent`, which `ChannelLogger`'s methods now take).\n- Added, in `@ggui-ai/protocol` and `@ggui-ai/mcp-server-handlers` (#1609): `ggui_runtime_report_render_failure`, an app-only runtime tool a card uses to say its render failed. It carries the phase (`mount` or `update`), the thrown value's class name (a code identifier, or `\"Error\"`) and the catch count, never a message or a stack. The server records a report only for the session the call's view proof binds, answers `ok` whatever the verdict, and never treats a report as an agent turn. `@ggui-ai/mcp-server` names each proven report (`render_failed`), and an unproven one without its session (`render_failure_unproven`). A deployment keeps a mark through the handler's `recordRenderFailure`. No card sends it yet (SPEC §4.9). New exports: `reportRenderFailureInputShape` / `Schema`, `reportRenderFailureOutputSchema`, `GguiReportRenderFailureInput` / `Output`, `RENDER_FAILURE_PHASES`, `RENDER_FAILURE_ERROR_NAME_PATTERN`, `RENDER_FAILURE_MAX_CATCHES`, `RENDER_FAILURE_MAX_ID_LENGTH` (`sessionId` and `appId` are 1 to 256 characters), `renderFailureErrorName`, and `createGguiReportRenderFailureHandler`. A card sends at most one report per session. `VIEW_PROOF_V1_BOUND_ARGS` gains the tool's row.\n- Added, in `@ggui-ai/mcp-server-core` (#1568): `RenderIdentityRecord.requestedVariantKey`, the `variantKey()` of the variance the agent itself named for a render (its render `override.variance`, else its handshake draft's), beside the `variantKey` that was served. The two differ exactly when something other than the agent chose the served variance. `ggui_render` writes it on every commit. A record written before it existed reads without it, and absent means unknown, never the default variant. The render-identity refresh (`ggui_update`, `ggui_runtime_sync_context`) now carries every member it does not refresh from the stored record, so it keeps this member and any added later. `runRenderIdentityStoreConformance` asserts the round-trip and the absence.\n- Added, in `@ggui-ai/ui-gen` (#1398): the runtime-render check reads whether a control whose click dispatched an action visibly shows it is working while the action is pending (disabled, busy, new text, or replaced), and reports it as `EvalResult.runtimeProbe.pendingAffordance`: `{ dispatched, visible, missing, gone }`. It runs after every other check, so it cannot change what they find. A form is read through its control that names the action. A pressed control stays that action's control while it is on the page, even when it relabels itself. One that left the page, or now names another action, is `gone`, never credited to another control. It is report-only: it adds no issue and doesn't change the probe's verdict. It is absent when no action walk ran, or when too little of the check's time was left.\n- Added, in `@ggui-ai/mcp-server-handlers` (#1568): a deployment can reserve rows from the semantic (similar-interface) reuse tier. `MatchBlueprintOptions.excludeFromSemantic` names rows by `semanticExclusionKey(contractKey, variantKey)`. A named row is never offered to the reuse judge or returned as a candidate, and it stays reachable by its exact key. `HandshakeDecisionAdapter.semanticExclusion` answers once per request: `{ kind: 'rows', keys }`, or `{ kind: 'all', reason }` when the deployment cannot say which rows are reserved. `'all'`, or a hook that fails, turns the semantic tier off for that request rather than risk offering a reserved row. `MatchBlueprintOptions.disableSemanticCause` names why the tier is off, so a trace never reports the exact-only policy for an exclusion outage. Nothing changes for a deployment that sets neither.\n- A theme's `motion.reduce: 'ignore'` is accepted and treated as `'respect'` (#1326). A card never overrides its viewer's reduced-motion request: the reduced-motion rule applies under `prefers-reduced-motion: reduce` for every theme, because the preference is the viewer's own accessibility setting. That was already the behaviour; the theme types and the document door now say so, and a stored document that says `'ignore'` still parses.\n- The host-helper conformance kit grades the two host obligations of the view-origin proof (#1415, SPEC §4.7), when the host supplies its rules. `V1-view-material-withheld` feeds the host's model-context rules a render result and a render read body whose `_meta[\"ai.ggui/render\"]` slice carries a marker in every value. None may reach the model, raw, percent-encoded or inside a base64 run, the result's own marker must, and the host's model-facing resource list, when it has one, must withhold `ui://ggui/render/*`. `L1-view-locator-binding` feeds the host's relay decision a view's own and another session's reads, in every locator form, and every session-naming call a view may make. Both are optional (`modelContext`, `viewBinding` options) and grade the rule, not a running host.\n- On hosted GGUI, TypeSafe AI's Jev model now decides whether a render reuses a cached interface, and which one (#1235). GGUI's LLM judge decides when Jev does not answer in time, returns an error, or is not confident. Jev is shown the same fields the judge is, except the similarity scores, and what a render costs does not change. See [Trust & security](/hosted/trust/#what-generation-actually-sees).\n- The conformance kit's `n1-compat` catalog grades the view-origin proof across releases (#1415), on a new `view-proof` wire. A later proof version must still fit the relay door shape and read `version_unknown`, never `malformed`; a v1 proof whose root carries a claim a later release adds must still parse.\n- The conformance kit grades SHOULDs (#1526). A fixture may declare `\"level\": \"should\"`; an unmet SHOULD is a warning, never a failure: it lands in the new `ConformanceResult.warned` bucket with the same evidence a failure carries, streams as `WARN` through the new `ConformanceReporter.onFixtureWarn`, and is printed by the new `formatWarnings`. The CLI counts a warned fixture as executed and never changes its exit code for one. The first SHOULD case, `fresh-subscribe-replays-reserved-preview` (SPEC §12.2.1), expects a fresh subscribe to replay a known-reserved channel's retained envelope after the ack, at a `seq` at or below its `streamSeq`, graded by the new `replayAfterAck` option on a `stream-update` expectation. **Changed:** `ConformanceResult` gains the required `warned` field, so code that builds a `ConformanceResult` by hand adds `warned: []`; the runner always sets it.\n- `@ggui-ai/mcp-server` issues view keys (#1415). The render slice's new `viewKey` rides the `ggui_render` and `ggui_update` results and the data-plane `resources/read` of a render locator. It is minted with the slice's `wsToken`, which now carries the signing secret's key id and the door (`kid`, `src`). The control plane, isolated services, an anonymous caller, `ggui_list_sessions`, `/state`, the refresh and the console inspector never get one, and their tokens carry neither stamp; a read door that withholds one logs `view_key_not_issued` with why, as does a mint whose token is too long to carry a key. Under the development allow-all adapter every caller holds one identity and is keyed. A card from this release signs its runtime calls with it, and the server's gate measures them. `createGguiServer` gains `viewProof.keyedSince`, from which a call without a valid proof reads `current` rather than `legacy` on its line. A runtime from before this release ignores the field.\n- `GET /api/sessions/:id/state` logs `state_chain_expired {sessionId, rootAgeSec}` for each renewal it refuses because the chain expired: the presented token is past its `exp`, or its chain has no second left (#1540). Only a token the server signed is counted. A `rootAgeSec` at or past the refresh window is a chain that reached its bound, and a smaller one is a token that went unrenewed. The 410 is unchanged.\n- `GET /api/sessions/:id/events` logs `render_channel_polled_first {sessionId, appId, kind}` on its first successful read per server process, session and kind (#1614), so a view on the HTTP polling rung leaves a mount mark tagged with its app, as `render_channel_subscribed` does on the WebSocket and SSE streams. `kind` is `cold-mount` for the boot fetch (`sinceSequence=0&limit=1`) and `poll` otherwise. The line comes only after every gate passes, never on a refusal, and never carries the query string. The server remembers the last 10,000 session-and-kind pairs, so a pair read again after that many newer ones logs again. Responses are unchanged.\n- The live channel carries the stream counter's epoch (#1531). `@ggui-ai/mcp-server` stamps `streamEpoch` on every stream envelope and on the subscribe ack (from a buffer that has epochs; the in-memory one does). A subscriber whose session's counter restarts while it stays connected now receives the new generation's frames instead of losing every one whose `seq` is below its cursor, and the server logs `stream_epoch_changed` when that happens; a late frame of the epoch a subscriber left is dropped, so it never moves the subscriber back. A resume whose `fromEpoch` (WebSocket payload or `?fromEpoch=` on the SSE stream) is not the session's current epoch, or whose `fromSeq` is past anything the counter has assigned, replays everything retained and sets `replayTruncated`. A buffer without epochs behaves as before.\n- Added, in `@ggui-ai/protocol/integrations/mcp-apps` (#1415): `McpAppAiGguiRenderMeta.viewKey`, the view key a view proves its app-only runtime calls with, and `parseMcpAppAiGguiRenderMeta` keeps it only beside the `wsUrl` / `wsToken` pair it is rooted in and only in its 43-character base64url shape. No server issues one yet.\n- A card captures its render slice's view key root (#1415, the view-origin proof; nothing signs with it yet). That root is `P`, the payload segment of the slice's `wsToken`, and the slice's `viewKey`. `McpAppAiGguiMetaParseResult` (`@ggui-ai/iframe-runtime`) carries it as `viewRoot` on the `ok` and `EXPIRED_BOOTSTRAP` arms, beside the meta and never inside it, captured before an expired credential leaves the meta. `ViewRoot` is exported. The card adopts a root only when `P` fits a proof and decodes to the slice's own session. For that session it moves only to a later `iat`, and a later slice without a key never replaces it. Added, in `@ggui-ai/protocol/integrations/mcp-apps`: `decodeViewRootClaims`, which decodes a root to its claims, unauthenticated, and names why a string is not one (`malformed`, `wrong_kind`).\n- Once a card's boot slice is resolved, the runtime removes the inline copies of its envelope that the shell left behind (#1415): `globalThis.__GGUI_META__`, the pre-load `__GGUI_PENDING_TOOL_RESULTS__` buffer, and the self-contained shell's envelope `<script>` element, whose text outlives the global. Nothing in the runtime reads them after boot. Code that read `__GGUI_META__` after the runtime booted no longer finds it. This is hygiene, not a boundary: card code runs in the same realm and can still ask the host to read its own locator.\n- A card proves its app-only runtime calls (#1415, the view-origin proof; the server measures it and refuses nothing at this release). Every `tools/call` of `ggui_runtime_submit_action`, `ggui_runtime_sync_context` and `ggui_runtime_pull` the card sends (a dispatch and its audits, the context mirror, the bridge's pull) carries a v1 proof at `params._meta[\"ai.ggui/view\"]` when the card holds a view key root for the call's session. It sends the JSON-normalized arguments it signed. A dispatch signs whether the gesture carried user activation (measured, never enforced). Any other tool is sent unchanged. Signing is synchronous, through `@noble/hashes` (a new dependency of `@ggui-ai/iframe-runtime`), and total: a card that cannot sign sends the call without a proof. A dispatch answered `VIEW_ORIGIN_UNPROVEN` shows the ordinary error toast and never reads as a host that cannot relay. A relay forwards `_meta[\"ai.ggui/view\"]` byte-identical. On a call made through the MCP Apps `App`, `_meta` also carries the MCP SDK's own `progressToken`, as it already does today.\n- Added, in `@ggui-ai/protocol` (#1531, the live channel's stream counter generation): `StreamEnvelope.streamEpoch`, `AckPayload.streamEpoch` and `SubscribePayload.fromEpoch`, all optional, and `makeStreamEnvelope` stamps a `streamEpoch` beside a `seq`, never alone. A new epoch means the session's counter restarted and `seq` values may repeat: a client that dedupes by `seq` resets to nothing applied instead of dropping every frame, and a server holding another epoch than a resume's `fromEpoch` replays everything it retains. An absent epoch is unknown, never a mismatch.\n- A card reads the stream counter's generation (#1531). Its `seq` dedupe resets to nothing applied when an envelope or a subscribe ack names a different `streamEpoch` than the one it last saw, so a restarted counter's frames are applied instead of dropped as repeats. A resumed subscribe sends `fromEpoch` beside `fromSeq`, in the WebSocket frame and as `&fromEpoch=` on the SSE stream, and never without it. A server that sends no epoch gets the earlier behaviour, including the reset on an ack whose `streamSeq` is below the cursor. Added: `RegistrySseOptions.fromEpoch` (`@ggui-ai/live-channel`), a getter read only when `fromSeq` gives a cursor.\n- `ggui_runtime_submit_action`, `ggui_runtime_sync_context` and `ggui_runtime_pull` verify a view proof when a view sends one at `params._meta[\"ai.ggui/view\"]` (#1415), and name the verdict on their `tool_invoked` line. No call is refused or answered differently. The line carries `viewProof` (`valid`, `missing` or `invalid`). When the proof is not valid it adds `viewProofReason`, the caller's `claimedSessionId`, and for a dispatch or a sync `viewProofEra`, read from the session's row. When it is valid it adds `viewProofSessionId`, the proof root's age and door, a bucket of the view clock's skew, user activation on a dispatch, and `viewProofRepeat` when this server saw the same proof nonce for the session within 10 minutes. The proof, its nonce and its tags are never logged. These lines, and `ggui_runtime_declare_tool_catalog`'s, also carry `authSource`. A refused sync now shows `ok: false` and its `code` on its line. A server with MCP Apps off keys no view and does not verify proofs: its lines say `viewProofUnverifiable: true`, and it logs `view_proof_unverifiable` once at boot.\n- Added, in `@ggui-ai/mcp-server-handlers` (#1415, the view-origin proof; measured, never refused): `SharedHandler.viewProof`, which declares whether a tool's calls carry a view proof and what it is for (`ggui_runtime_submit_action` requires one for `kind: \"dispatch\"` and measures the other kinds, `ggui_runtime_sync_context` requires one, `ggui_runtime_pull` measures one), with `viewProofUseFor`; `HandlerContext.viewProof`, the verdict the transport will put there; and `HandlerContext.sessionRows` (`createSessionRowReads`, `readSessionRow`), a per-request memo so a proof gate and the handler read a session's row once. `ggui_runtime_submit_action` and `ggui_runtime_sync_context` now declare `VIEW_ORIGIN_UNPROVEN` on their closed output schemas in `tools/list`. No server answers with it at this release: a client that cached the schema will accept it when a later release does.\n- Added, in `@ggui-ai/protocol/integrations/mcp-apps` (#1415): `VIEW_ROOT_SRC` (`['result', 'read']`), `ViewRootSrc` and `isViewRootSrc`, the one list of doors a view root's `src` may name. It is a closed set: a verifier refuses any other value, so a new door is added to the list one release before any door mints with it. `ViewRootClaims.src` and, in `@ggui-ai/mcp-server-core`, `WsTokenClaims.src` are typed `ViewRootSrc`.\n- Added, in `@ggui-ai/mcp-server-core` (#1415, the view-origin proof's server side; no door calls it yet): `mintViewRoot`, a root ws token stamped with the signing secret's key id and the issuing door (`kid`, `src`), and the view key derived from its payload, or `viewKeyNotIssued: 'oversize'` when the payload is too long for a proof; `defaultViewKid`, `deriveViewKey`, `viewRootFits`; `verifyViewProof`, which reads the proof from the request's `_meta` and names the first failing check (`missing`: `meta_absent` or `key_absent`; `invalid`: the grammar, `unknown_key`, `bad_mac`, `session_mismatch`, `app_mismatch`, `args_mismatch`) and never throws (an internal failure is `verifier_error`, with the class of what was thrown); and `ViewProofRepeatCache`, a bounded replica-local record of seen `(session, nonce)` pairs, O(1) per observation. `WsTokenClaims` gains the optional `kid` and `src`; tokens without them verify as before.\n- Added, in `@ggui-ai/protocol/integrations/mcp-apps` (#1415, the view-origin proof, not yet used by any server or runtime): the request key `MCP_APP_AI_GGUI_VIEW_META_KEY` (`ai.ggui/view`), the relay door shape `VIEW_PROOF_RELAY_SHAPE`, the v1 grammar `VIEW_PROOF_V1_PATTERN`, the labels and size bounds, the bound-argument table `VIEW_PROOF_V1_BOUND_ARGS`, the byte builders `viewKeyInputBytes`, `viewProofArgsBytes` and `viewProofCallBytes`, `parseViewProof` and `formatViewProofV1`, the result code `VIEW_ORIGIN_UNPROVEN`, and a known-answer vector `VIEW_PROOF_V1_VECTORS`. The module holds no crypto; a signer and a verifier HMAC these bytes and reproduce the vector.\n- A live-channel subscribe without `fromSeq` no longer carries `replayTruncated: true` on its ack when the stream buffer reports its reserved-channel walk truncated, for example once a session's retention has expired. `AckPayload.replayTruncated` is documented as absent on fresh subscribes, and a fresh client has no history that could have a gap.\n- A live-channel subscribe that fails after the server registered the subscriber, such as a failed event-ledger read, no longer leaves that subscriber behind. The client may already hold the ack, so the server unregisters the subscriber and ends the transport: close code 1011 on the WebSocket, the stream ended on SSE. The client re-subscribes with its ordinary backoff and gets a fresh replay, instead of running on a partial one with no signal. The first page of the ledger is read before the ack, so a read that fails sends no ack, and a client that resets its retry budget on an accepted ack still backs off to its cap. It is not 1012, whose retry comes at once, because a failure that repeats for one session would then become a tight reconnect loop. Before, an SSE subscriber was never unregistered, and a WebSocket stayed half-subscribed. (#1528)\n- The live channel keeps its order, ack → replay → live, on a subscribe that asks for the event ledger (`sinceSequence`), which every SSE open does. The server awaits the ledger read before writing the replay, and a live frame arriving in that window, published on this server or delivered from another one, used to reach the client first, ahead of replay frames with lower `seq`. A client that dedupes by `seq` then dropped the replay. Such frames are now held and sent after the replay. (#1525)\n- SPEC erratum (§12.2.1), with no wire change: a subscribe without `fromSeq` replays no channel the render declares, as before, but the server SHOULD send the retained envelopes of the reserved `_ggui:*` channels right after the ack, at `seq` at or below the ack's `streamSeq`, as both reference servers already do (so a late viewer sees the `_ggui:preview` skeleton). A client MUST apply those frames, and dedupe against the highest `seq` it has applied, never against `streamSeq`. The old text said a fresh subscribe replays nothing and told clients to seed `lastSeenSeq` from `streamSeq`; a client that did so dropped the reserved replay. `SubscribePayload.fromSeq`, `AckPayload.streamSeq` and `StreamEnvelope.seq` in `@ggui-ai/protocol` are re-documented to match. (#1521)\n- `@ggui-ai/protocol/wire` exports the Plane-2 reader, so a browser can read a tool's `<code>: <detail>` error text: `parseDomainErrorText`, `ParsedDomainErrorText`, `isDomainErrorCode`, `DOMAIN_ERROR_CODES` and `DomainErrorCode`. The reader carries only the code names (about 0.6 KB minified), not the registry's descriptions: the names now live in a module of their own, and the registry rows are typed exhaustively over them. The root exports are unchanged. Changed (VERSION-POLICY §3.7, compatible): `DOMAIN_ERROR_CODES` is a readonly tuple, in the same order, instead of a `readonly DomainErrorCode[]`.\n- `ggui_runtime_sync_context` answers a session read that fails exactly as it answers a session that does not exist, `SESSION_NOT_FOUND`, as `ggui_runtime_submit_action` already did with `PIPE_NOT_FOUND`, instead of throwing the store's error. It still writes nothing. The cause is logged on the `runtime_ownership_unverified` line, which now carries the store's error text. The cross-app class contract test gains a failed-read arm: both runtime write tools must answer it byte-identically to a missing session, and every other session-keyed tool is pinned as it answers today (they throw). (#1514)\n- A relay that retries a `ggui_runtime_submit_action` dispatch with the same `actionId` (for example after losing the response) no longer writes a second `user.submitted` ledger row. The dispatch appends to the pipe first and writes the ledger row only when the pipe stored the gesture; the retry is logged as `submit_action_duplicate_dispatch`. The `oneShot` spend is applied on a retry too, because recording it twice is one spend, so a gesture whose first call failed between the append and the spend is still spent. Changed (VERSION-POLICY §3.7, compatible): `PendingEventConsumer.append` (`@ggui-ai/mcp-server-core`) resolves `'appended'` or `'duplicate'` (`PendingEventAppendOutcome`). An adapter that resolves nothing still conforms and is read as `'appended'`, which keeps the earlier behaviour. The published `runPendingEventConsumerConformance` suite asserts the outcome. (#1517)\n- The self-contained shell (the per-render document a `resources/read` of a `ui://ggui/render/…` locator returns) now reports a runtime bundle that fails to load: it posts one `ggui:bootstrap-failed` with `BUNDLE_FETCH_FAILED` to the host, after retrying a content-hashed URL's unhashed twin, or at once for any other URL, as the thin shell already did. Before, the host heard nothing. The card itself is unchanged: it keeps its loading mark (or stays blank without one), and only the host is told. The runtime `<script>` of every such shell now carries `data-ggui-runtime=\"src\"` beside a small inline listener, so its bytes change. (#1503)\n- `ggui_ops_register_blueprint` (and `createRegisterGeneratedBlueprint`, which shares its core) and `ggui_ops_generate_blueprint` mirror a registration into the cache under the durable row's own id. A card served from that cache row records that id, and the store a re-mint reads now holds the row and its body under it, whether that store is the ops store itself or a separate one the mirror writes through to. So re-minting the card's locator finds the blueprint instead of reporting it gone. A mirror that writes through to the ops store itself no longer leaves a second durable row per registration. Cache rows bound before this keep the id they were served under. (#1497)\n- The live channel no longer mints a reconnect `sessionToken` into the subscribe ack (#1488). No server ever verified that token, so on the bearer reconnect it was documented for it proved nothing of its own, and no client read it; a reconnect presents a live `wsToken` again. `AckPayload.sessionToken` stays declared, optional and `@deprecated` through this release, and is removed in the next (VERSION-POLICY §3.6). The `render_channel_subscribed` log now reports the credential a subscribe came on: `bootstrap` (true for a wsToken, on the WebSocket or the SSE stream) and `source` (`ws_token`, `console_cookie`, or the auth adapter's source).\n- Removed (a pre-1.0 minor, VERSION-POLICY §3.7), each with no replacement: `mintSessionToken` and `DEFAULT_SESSION_TOKEN_TTL_SEC` (`@ggui-ai/mcp-server-core`), and `GguiSessionChannelBootstrap.issueSessionToken` (`@ggui-ai/mcp-server`). `TokenKind` keeps `'session'`, deliberately, until the release that removes `AckPayload.sessionToken`: `kind` is a claim inside tokens that cross the wire, and a session token an earlier release minted must still be refused as `wrong_kind`.\n- Removed (a pre-1.0 minor, VERSION-POLICY §3.7), each with no replacement: the variant selector, which nothing called. In `@ggui-ai/mcp-server-core`: `selectVariantWithLlm`, `encodeSelectedReason`, `preFilterCandidates`, `computeVariantSelectionCacheKey`, the `VariantSelectionContext`, `VariantSelectionDecision`, `VariantSelectionCache`, `VariantSelectionCacheEntry`, `VariantSelectionPickFn`, `VariantSelectionResult` and `VariantSelectorWithLlmOptions` types, the `DEFAULT_VARIANT_SELECTION_CONFIDENCE_THRESHOLD`, `DEFAULT_VARIANT_SELECTION_CACHE_TTL_SEC` and `DEFAULT_VARIANT_SELECTION_SHORTLIST_SIZE` constants, `BlueprintSelector`, `BlueprintSelectorContext` and `createDeterministicBlueprintSelector`, and on `/in-memory` `InMemoryVariantSelectionCache` and `InMemoryVariantSelectionCacheOptions`. In `@ggui-ai/mcp-server-handlers`: the optional `HandshakeNegotiator.selectVariant` method. In `@ggui-ai/mcp-server`: `createGguiServer`'s `blueprintSelector` option and `GguiServer.blueprintSelector`. None of these types a wire member. `BlueprintMeta.selectedReason`, the `handshake.decided` event and `extractSelectionConfidence` stay: a `conf=<n>` suffix on `selectedReason` is still read into the event's `selectionConfidence`, and nothing in `@ggui-ai/*` writes one.\n- Removed (a pre-1.0 minor, VERSION-POLICY §3.7), with no replacement: `GenerationDeps.resolveLlmCaller` in `@ggui-ai/mcp-server-handlers` (#1625). Nothing read it: the render handler resolves the blueprint the handshake decided, and the matcher's judge is wired through the handshake negotiator. It does not type a wire member. `GenerationDeps.installedBlueprints` stays, because `createGguiServer` forwards it to the handshake negotiator.\n- Generations now stamp the durable blueprint record with the build that made them (`Blueprint.build`, declared in 0.25.0): a `ggui_render` cold generation and `ggui_ops_generate_blueprint` write the generation's `build`, and so does `createRegisterGeneratedBlueprint` when its caller passes the generation's build (`GeneratedBlueprintBytes.build?`), on the durable row and on the row a cache mirror writes through to. A dedup keeps the row's first stamp. Rows registered through `ggui_ops_register_blueprint`, installed and seed-imported rows carry none: the operator door takes no build (its schema names none, and over MCP an unknown `build` is stripped before the handler runs). `admitGeneratorBuild` (`@ggui-ai/mcp-server-handlers/renders`) is the one admission rule: a stamp is kept only on LLM-generated code and only when it passes `generatorBuildSchema`, else it is dropped and the row registers unstamped. `RegisterBlueprintInput` gains `build?`. The conformance kit gains an `ops-list-blueprints` n1-compat wire. (#1280)\n- `ggui_consume` now confirms the session belongs to the caller before it registers as that session's consumer. Before, a consume refused for another app's session counted as that session's consumer while its store read ran, and its end stamped the session's last consumer exit, which changed how long that session's dispatches waited for a consumer over the next minute. `InMemoryActiveConsumerRegistry` also forgets a consumer's exit after 60 s (`ACTIVE_CONSUMER_EXIT_RETENTION_MS`, exported from `@ggui-ai/mcp-server-core/in-memory`), so it keeps the last minute's exits instead of one per session that ever had a consumer; `msSinceLastExit` reads `undefined` past that. `ggui_runtime_submit_action` waits as before, except for a session re-rendered in place after its consumer exited (on the in-memory and SQLite stores), which now waits as a fresh render does for 60 s; it also reads the session row once per dispatch instead of twice. (#1485)\n- Snapshot-read renewals are bounded (ggui#1496). `/api/sessions/<id>/state` still renews the wsToken it is given, but each renewal carries its chain's root forward as a new optional `rootIat` claim, and its expiry never passes `rootIat` plus the refresh window. A chain of renewals therefore ends one window after its root was minted, and `/state`, `/events`, `/stream` and the live channel all refuse its last token in the same second, as long as every server sharing the secret uses the same window and it has not shrunk. After that, a new credential needs an authorized door, such as `ggui_runtime_refresh_ws_token`, which mints a new root for a caller admitted to the session. A token without `rootIat` (every earlier token) is its own root.\n- Added: `createGguiServer` options `wsTokenTtlSec` (default 180, the lifetime of the ws tokens the server mints; a snapshot-read renewal gets less when its chain's window ends sooner) and `wsTokenRefreshWindowSec` (default twice the TTL; the factory throws when it is shorter than the TTL); `WsTokenClaims.rootIat?` and `MintWsTokenInput` (`mintWsToken`'s input, with `rootIat?`) in `@ggui-ai/mcp-server-core`.\n- Changed (VERSION-POLICY §3.7): `WsEnvelopeVerdict.iat` (`@ggui-ai/mcp-server-handlers`) is replaced by `rootIat`, the chain root's issued-at (a renewal's `rootIat` claim, else its `iat`), so the refresh log's `rootAgeSec` ages the chain rather than its last link.\n- A render's `postSuccessHook` now receives the token counts the generation reported, on `generation.usage` (`GguiSessionPostSuccessArgs`, `@ggui-ai/mcp-server-handlers`): `inputTokens` (the input not served from a prompt cache), `outputTokens`, and `cacheReadTokens` / `cacheCreationTokens` when the provider reports them. With `@ggui-ai/ui-gen`'s generator those are its coding turns and any in-loop text-evaluation rounds, summed; the in-loop visual judge's calls are not included. `generation` stays `null` on a blueprint reuse. (#1513)\n- A card that receives live updates through the host's `tools/call` bridge (`ggui_runtime_pull`, the path for hosts whose iframe can't reach the server) now pulls again as soon as a pull returns, as the bridge's subscription mode was designed to. It no longer waits 15 seconds after every successful pull, so an update landing between pulls reaches the card up to 15 seconds sooner. After three pulls in a row that return nothing, the card still drops to pulling every 15 seconds. (#1527)\n- A card whose live credential (`wsToken`) expires now renews it through its host instead of going quiet. It renews at mount, when the slice it booted from has expired. It also renews mid-session: when the server refuses the credential as expired (`BOOTSTRAP_EXPIRED`), when HTTP polling answers `410`, or when the card falls back to the host's bridge after its expiry. It calls `ggui_runtime_refresh_ws_token` once per credential through the host's `tools/call` relay. It then moves its live channel onto the new credential and resumes from the last update it applied. A card whose session is confirmed gone, or whose host confirms it cannot relay `tools/call`, says so and stops pulling, keeping what it painted. Stream envelopes (`data`) are now applied once each by their `seq`, and a resumed subscribe passes the last one applied as `fromSeq`. A subscribe ack whose `streamSeq` is below that cursor shows the server's counter restarted. The card then forgets the cursor instead of dropping the new envelopes as repeats. (#1496)\n- Changed (VERSION-POLICY §3.7): in `McpAppAiGguiMetaParseResult` (`@ggui-ai/iframe-runtime`), the `ok` arm gains `held?`, the expired live credential the parse now keeps instead of discarding. The `EXPIRED_BOOTSTRAP` arm now carries `meta` and `held`. `HeldCredential` is exported. (#1496)\n- Added: `RegistrySseOptions.initialSinceSequence` (`@ggui-ai/live-channel`) also accepts a function, read when the SSE rung connects. The new `RegistrySseOptions.fromSeq` getter appends the stream cursor (`&fromSeq=`) to every connect. (#1496)\n- `inLoopEvaluation` and `runsInLoopEvaluation` (`@ggui-ai/ui-gen`) name which in-loop evaluation legs a generation runs: the text evaluator when `evaluation.enabled`, the visual judge when `visualEvaluation.enabled` or `qualityConfig.visualEval`. They are the harness's own gate, so a host pricing a generation's `usage` can tell whether those counts may include evaluation calls, with the options it configured the generator with. (#1513)\n- A card's WebSocket counts a subscription as accepted only once it has held for 5 seconds. A server whose subscribe fails right after its ack, and closes, no longer resets the card's retry budget on every attempt. The card backs off as for any failed attempt and, after the usual ten retries, moves to its next transport. It used to retry every second indefinitely. (#1533)\n- The generation's cost cap (`qualityConfig.maxCostPerGeneration`, `@ggui-ai/ui-gen`) now counts all of a generation's spend, so under the same cap the eval loop can stop sooner than before. It now counts the in-loop visual judge's calls, including a configured criteria bank's report-only calls (the cap bounds money spent, so these count toward it, through their cost and never their answers), and the prompt cache's reads and writes, priced at the model's cache rates. A model with no cache-write rate is priced at the largest write premium the model registry states, so a write is never counted under. Added: the generation result carries the in-loop visual judge's spend as its own part, `inLoopVisualTokens` (beside `tokens`, never inside it).\n- A tap the card's own action contract refuses is no longer silent (#1536). The runtime posts an `action-refused` observability event to the embedding host (`ggui:observe`), naming the render, the action and each violation by its field path and schema keyword, never by value. It posts at most ten per render, and marks the tenth `capped: true`. The visitor sees the ordinary error toast, named by the action's declared label. Nothing is sent, as before. Added: `ActionRefusedEvent` (`@ggui-ai/iframe-runtime`), and, in `@ggui-ai/wire`, `BuildWireConfigOptions.onDispatchRefused` with its payload `DispatchRefusedInfo`, called after `onViolation` with the refused action's name.\n- ggui's own relays carry a view's proof, and refuse a view's call to a model-only tool (#1415). `@ggui-ai/agent-server`'s `POST /agent {kind:'tool-call'}` takes an optional `meta`, the view's request `_meta`, and forwards only its `ai.ggui/view` string, verbatim, as the relayed call's `_meta`. A call to a tool whose declared `_meta.ui.visibility` lacks `app` (`ggui_render`, `ggui_update`) is answered `-32602` `Unknown tool: <name>` and never relayed. The relay reads each server's `tools/list` at most once every 5 minutes, and once more for a name it has not seen. When a re-read fails, it keeps answering from the last list it read and logs `relay_tool_visibility_stale`. A tool it has never seen while the list cannot be read is answered `-32603` `tool visibility unavailable`. In `@ggui-ai/mcp-apps-react-native`, `onToolCall` gains an optional third argument, `McpAppViewCallMeta` (the proof alone), and `<McpAppIframe>` gains a `toolVisibility` prop that refuses the same calls. The `ggui-basic-web` sample sends its view's `_meta` to the relay. Added, in `@ggui-ai/protocol/integrations/mcp-apps`: `toolVisibleToApp`, the app-side twin of `toolVisibleToModel`.\n- A card never moves its stream cursor back to an epoch it has left (#1531). It remembers the last four epochs it left, drops their stamped frames and ignores their acks, as the server does. Before, a late frame of the old generation moved it back with nothing applied, and the next frame of the new generation moved it forward again with an empty floor, so a duplicate in the new generation could pass. An unstamped frame still always passes, and no longer moves the epoch.\n- `ggui_ops_delete_blueprint` takes a blueprint out of reuse (#1541). `ggui_ops_register_blueprint` and `ggui_ops_generate_blueprint` write a blueprint twice: its durable row, and a mirror in the cache the handshake's exact-key probe matches from. Delete removed only the durable row, so the next handshake on the same contract still reused the deleted blueprint. It now removes the cache entry (its vector and its exact-key binding) first, then the durable row. A cache delete that fails fails the call with the row intact, so a retry completes it. Re-running a delete for a blueprint whose row an earlier delete already removed clears its leftover cache entry, in the caller's own app only. Added: `GguiOpsDeleteBlueprintDeps.cacheRegistry` (`@ggui-ai/mcp-server-handlers`), which `createGguiServer` binds to the same registry as the writers.\n- The ggui dark theme's muted text is dimmer: `onSunken` is `#a3a3a9` (6.57:1 on a card, 5.80:1 on a sunken well), where it was `#d9d9d9`, close enough to the primary text that secondary text did not read as secondary (#1566). Six components drew text you read with the muted role, in both themes, and now draw it with `onContainer`: the secondary and ghost `Button` labels, the option labels of `Checkbox`, `RadioGroup` and `Toggle`, `Accordion` answers, `Table` body cells, a `Select`'s displayed option, and the other party's messages in `ChatWindow`. What is muted stays `onSunken`: option descriptions, the `RadioGroup` field label, the `Table` header and caption, a `Select` placeholder while it shows, and the other party's timestamps. In the light theme that text moves from `#5a5a5a` to the card's ink.\n- Added, in `@ggui-ai/ui-gen`: `visualEvaluation.actOn` (`'critical'` or `'major'`, default `'critical'`), which of the in-loop visual leg's own findings may start another eval round when the quality mode is `fast`. With `'major'`, the visual judge's major findings start a fix round as a failure does; the fit measurement's warn-level overflow and the text evaluator's warnings never count. The default decides exactly as before, and the other quality modes, which already act on every warning, are unchanged. A round that continues with the setting present logs why (`continuing: fails=…, visual majors acted=… (actOn=…)`). (#1542)\n- `ggui serve` keeps a session's live-stream counter across a restart (#1534). A server that stores its sessions in SQLite, as `ggui serve` does by default when `better-sqlite3` is installed (unless you pass `--ephemeral`), and whenever `ggui.json` declares `storage.renders` with `driver: \"sqlite\"`, now keeps the live channel's stream buffer in the same database: each session's `seq`, its `streamEpoch` and the envelopes it retains for replay. After a restart, a session continues its `seq` in the same epoch, and a client that reconnects with `fromSeq` gets the frames it missed. Before, every restart started each surviving session's `seq` again at 1 in a new epoch, with nothing retained. Added, in `@ggui-ai/mcp-server-core`: `SqliteGguiSessionStreamBuffer` (`/sqlite`), and `runGguiSessionStreamBufferConformance` (`/contract-tests`), the stream buffer port's conformance suite, which the in-memory and SQLite buffers both run. `resolveStorageFromConfig` returns the buffer as `streamBuffer`, and `createGguiServer` keeps its in-memory default unless you pass one.\n- A retried `ggui_runtime_submit_action` dispatch is one gesture, promised on the wire (#1519, SPEC §11.1). A host or relay that could not confirm a dispatch may retry it: a server SHOULD answer a repeat of an earlier committed `(sessionId, actionId)` as it answered the first, with no second pending event, no second `user.submitted` row and no second spend, even when both requests are in flight at once. The promise becomes a MUST in the next release. `@ggui-ai/mcp-server` already behaves this way. An `actionId` reused for a different gesture (a different `intent` or `actionData`) keeps the first gesture. This release the second is still answered as a duplicate and dropped, so the view shows success, but no longer without a trace: it is logged as `submit_action_action_id_reused`, and nothing of it is spent. Its answer, `ACTION_ID_REUSED`, is declared in the tool's output schema this release and emitted from the next, when the view shows the user an error toast (as on any `ok: false`); the second gesture still does not reach the agent. SPEC §11.1 names the promise's two residuals. Agents keep deduping consumed events by `actionId`: the promise serves relays. Added: `dispatchGestureBytes` and `ACTION_ID_REUSED` (`@ggui-ai/protocol/integrations/mcp-apps`); `PendingEventConsumer.append` takes an optional `gestureDigest` and reports `'conflict'` (`@ggui-ai/mcp-server-core`), and the SQLite pending-event store adds its digest column on open. If you run `runPendingEventConsumerConformance` against your own store, its three conflict cases run only when your factory sets `reportsConflict: true` (they are skipped otherwise, and become required with the MUST); the `dispatch-idempotency` conformance catalog (`@ggui-ai/protocol-conformance/dispatch-idempotency-conformance`), at SHOULD level.\n- `ggui_runtime_submit_action`'s description in `tools/list`, and its two `PIPE_NOT_FOUND` messages, no longer say the view falls back to `ui/message` (#1571). The view never did: on any `ok: false` it shows the user an error toast and sends nothing (SPEC §4.7). The texts now say the gesture was not enqueued. `ggui_render` also logs `[ggui_render.pipe_open_failed]` with its reason when a session's pending-event pipe fails to open; before, that failure was silent, and the only sign was each later gesture on the session being refused `PIPE_NOT_FOUND`.\n- A blueprint row that copies another row's bytes can say so (#1570). `Blueprint.clonedFrom` (optional) names the row it copies. The copy's `source`, `build` and `codeHash` stay its original's, which are true about the bytes, and nothing that serves reads the new field. It is declared this release and written from the next, because the previous release's strict `blueprintSchema` would refuse a row that carries it. The portable form does not carry it. If you implement `BlueprintStore`, persist it verbatim: the kit's store suite now grades that. Two things to know if you read rows: a row without it is not necessarily produced in your store (an imported row carries its `source` and no `clonedFrom`), and removing a copy is a row delete, never a content takedown, because a copy shares its original's body.\n- Changed, in `@ggui-ai/mcp-server-handlers`: `ggui_render`'s `checkRenderContracts` seam now receives exactly the shape its type declares, `{ actionSpec?, streamSpec?, agentCapabilities? }`, built from the render and its contract (#1579). It used to receive the whole committed session, with the contract's `agentCapabilities` spread onto it. `ComponentGguiSession` declares no such field, so the committed session row carried an undeclared property that nothing read after the check. The row no longer carries it. If your own binding read anything else off its argument, read it from the render instead; the schema-compat check itself sees exactly what it saw before.\n- The `default`, `aggressive` and `always` MCP instructions presets (`@ggui-ai/mcp-server`) are corrected to match the shipped tools (#1579). Among the fixes: after consuming a gesture, the agent repaints the same card with `ggui_amend` (they said to render a new UI); an event names its action in `intent` and carries its payload in `actionData` (they described a `nextStep` on `actionData`); a render response carries a `sessionId` and a `ui://` `resourceUri`, never a URL to show; the card itself posts the doorbell message when nobody is consuming; and `ggui_list_sessions` is described as offered only where a server registers it. `ggui_render`'s tool description, and the presets, no longer tell the agent to read a `fixBy` field that no refusal carries: retry an `after-fix` refusal only once you have performed `refusal.fix` yourself. The presets' opening rendering policy is unchanged.\n- Changed, in `@ggui-ai/mcp-server-handlers`: a credit ledger entry's `kind` on `ggui_ops_list_credit_transactions` is an open vocabulary (#1532). The known kinds, now exported as `KNOWN_CREDIT_TRANSACTION_KINDS`, are `free_credit`, `render_charge`, `topup` and `refund`, and a deployment may add its own. The output schema declares `kind` as a non-empty string where it was a closed enum, `CreditTransactionView.kind` is a `CreditTransactionKind` (a known kind, or any other non-empty string), and the descriptions tell a reader to accept an unknown kind as named, never as a render charge. If your code switches on `kind`, give it a default arm.\n- `ggui serve` enables the console's theme upload (#1579). The CLI built the uploader and never passed it to the server, so the upload answered 501 (`upload_not_configured`) under `ggui serve`, which its own message named as the way to get uploads. `ggui serve` also stops writing `render-signer-secret.hex` into the persistent directory. The server stopped reading it when render signing was removed with the `/r/` viewer; an existing file is harmless and can be deleted.\n- An MCP instructions preset or custom string you set now reaches hosts, and setting none sends none (#1579). `createGguiServer`'s `mcpInstructions` (`--mcp-instructions` / `GGUI_MCP_INSTRUCTIONS` on `ggui serve`) was passed where the MCP SDK does not read it, so no server's instructions ever reached a host; the `mcp_instructions_set` boot line was the only trace. It is now sent in the `initialize` result. The no-flag default is `off`: it was nominally `default`, never delivered, so an operator who set nothing sees no change. `mcp_instructions_set` and `mcp_instructions_off` now carry `sentInInitialize`.\n- Removed, in `@ggui-ai/protocol` and `@ggui-ai/mcp-server-handlers`: `validatorScore` on `ggui_ops_generate_blueprint`'s output (`opsGenerateBlueprintOutputSchema`) (#1579). It was read off the generator's metadata through an undeclared field that no generator sets, so it was never populated, and no release ever returned it. A client that cached the old `tools/list` expects an optional field it never received. `Blueprint.validatorScore` on blueprint rows is unchanged.\n- In a fullscreen canvas, a band that runs edge to edge keeps its content on the card's edge (#1556). The frame gives a card's content a 16 px inset, and a band that bleeds (a `surface=\"hero\"` band that opens the card, or any element that declares `bleed`) took that inset back for its content as well as its ground, so an opening band's heading sat 16 px left of the body and chips below it. The band's ground still meets the panel's edge; its content now keeps the inset, so a card whose edges agree in the chat card agree in the panel too. Only the left and right edges changed; a band's top and bottom are as before.\n- Added, in `@ggui-ai/mcp-server-handlers`: `GguiSessionPostSuccessArgs.proposalServed` (#1331), optional. `ggui_render`'s post-success hook now learns whether the render served the stored blueprint its handshake proposed, as proposed: `true` when the proposal was accepted and the proposed blueprint is what served, `false` on a re-aimed render (even to the same blueprint), a cold generation, a failed render, or a store that now serves a different card. It is the same predicate as the blueprint resolution event's `strategy: 'proposed'`, `served: 'stored'` and `blueprintId` equal to `proposedBlueprintId`.\n- Changed, in `@ggui-ai/mcp-server-core`: `BlueprintIndex.deleteId(scope, exactKey, expectedId)` takes the blueprint id the unbind is aimed at, and removes the binding only while the key still points at it (#1603). Deleting a blueprint, self-healing a dangling binding, and evicting a row each rebuilt a key and unbound whatever it held, so a key re-bound since to a newer design lost it, and the newer design dropped out of exact-key reuse. All four callers now pass the id they mean. A custom `BlueprintIndex` must remove the binding only when it points at `expectedId` (a no-op otherwise, and on a missing binding); `runBlueprintIndexConformance` grades it. A failed unbind after a delete or an eviction is now logged as `blueprint_index_unbind_failed`, where it was swallowed.\n- The operator-default mark now says what it does (#1611). `ggui_ops_update_blueprint`, `ggui_ops_generate_blueprint`, the ops schemas' `isOperatorDefault` / `setAsOperatorDefault` descriptions (which ship in `tools/list`) and the console's blueprint pages said the flag pins a blueprint, and the console said the handshake picks by it. The mark orders blueprint listings (the default first) and is shown to operators; it does not decide which blueprint serves a render, which the handshake resolves from the blueprint index. No behaviour changes.\n- A `Select` with a `placeholder` and neither `value` nor `defaultValue` now shows the placeholder, muted, until the user picks (#1569). The placeholder is a disabled first option, and nothing selected it, so the browser showed the first real option as if the user had chosen it, and a form reading the element's value got that option. It now starts on the empty choice, and the text reads at body strength once a choice is made. A `value` or `defaultValue` still wins, and a `Select` without a placeholder is unchanged.\n\n## 2026-09-28 — v0.25.0: four security fixes to cross-app access — upgrade if you run `@ggui-ai/mcp-server` — and a tapped control shows it is working\n\n- **Security fix (`@ggui-ai/mcp-server-handlers`):** `ggui_runtime_submit_action` and `ggui_runtime_sync_context` act only on a session that belongs to the caller's own app, as `ggui_runtime_pull` already did. A call for another app's session or a missing session is answered as not-found before any effect: `PIPE_NOT_FOUND` on `ggui_runtime_submit_action`, `SESSION_NOT_FOUND` on `ggui_runtime_sync_context`. A failed session read is answered `PIPE_NOT_FOUND` on `submit_action`; on `sync_context` it threw the store's error, with nothing written, until the next release (#1514). (Corrected: this note first said `PIPE_NOT_FOUND` for all three cases on both tools.) If your host relays runtime calls under a credential whose app differs from the render's (for example, on the universal endpoint with an identity-default app), those calls now get not-found. Relay them at the render's app endpoint.\n- **Security fix (`@ggui-ai/mcp-server`):** on the live channel's bearer path, a subscribe that declares an app other than the one its credential resolves to now needs the deployment's per-app authorization to pass, through a new optional `authorizeApp` seam that `createGguiServer` fills from `perAppRouting.authorize`. Otherwise it is refused with `APP_MISMATCH`. The token and console-cookie paths are unchanged. If your clients subscribe with a bearer credential and declare another app, make sure `perAppRouting.authorize` admits that pairing.\n- **Security fix (`@ggui-ai/mcp-server-handlers`), and a silent change:** a `ggui_render` that names a session the caller can't see (another app's or another subject's) now gets a fresh session id instead of writing into that session. The render succeeds under the new id, and the other session is never written. This applies only when a handshake negotiator sets `target.sessionId`. There is no error to catch, so if you pre-assign session ids, read the returned id.\n- **Security (`@ggui-ai/mcp-server`), new and opt-in:** `perAppRouting.perAppOnlySources` lists credential sources that are valid only at their own app's endpoint. Such a credential is refused on an MCP mount without an app in its URL, and authorized on the live channel.\n- The conformance kit does not yet grade cross-app refusal, so the four fixes above are verified by the packages' own tests, not by the kit.\n- `ggui_runtime_refresh_ws_token` is now an authorized re-mint. It re-mints a view's live credential, at any age, only for a caller the render read door admits to the envelope's session (the app, then the subject). A session the caller cannot see gets the same not-found error `ggui_runtime_pull` throws, and there is no refresh window. Its advertised output schema is unchanged.\n- Added: `withWsToken(url, wsToken)` in `@ggui-ai/protocol` (swaps the token in a token-bearing session-API URL); `verifyWsTokenSignature` in `@ggui-ai/mcp-server-core` (a ws envelope's signature and kind, at any age); `renderReadAllowed` and `RenderReadRowView` exported from `@ggui-ai/mcp-server-handlers/renders`; and `defaultHandlers`' option `render.wsTokenVerify`.\n- Changed: `GguiRefreshWsTokenHandlerDeps` is now `{ renderStore, verify, mint }`.\n- Removed (a pre-1.0 minor, VERSION-POLICY §3.7). Before 1.0, a minor release may remove an export without a deprecation window. A caret range such as `^0.24.0` never picks up a new minor, so nothing changes for you until you raise your range:\n  - `refreshWsToken`, `RefreshWsTokenOptions` and `RefreshWsTokenResult` (`@ggui-ai/mcp-server-core`), replaced by `verifyWsTokenSignature` plus the handler's gate and your minter;\n  - the `'refresh_window_closed'` member of `VerifyTokenFailure`, with no replacement;\n  - `WsTokenRefreshSeam` (`@ggui-ai/mcp-server-handlers`), replaced by `GguiRefreshWsTokenHandlerDeps`' `verify` and `mint`;\n  - `GguiSessionChannelBootstrap.refresh` and `GguiSessionChannelBootstrapRefreshResult` (`@ggui-ai/mcp-server`), with no replacement, because the channel never called it;\n  - `defaultHandlers`' option `render.bootstrapRefresh`, replaced by `render.wsTokenVerify`.\n- A card whose runtime bundle URL carries a content hash that no serving replica has (during a rolling deploy, after a rollback, or on a replayed card) no longer fails at once: both shells retry the bundle's unhashed twin, same origin and path, one time (the thin shell then reports `BUNDLE_FETCH_FAILED` as before; the self-contained shell still reports no bundle failure, tracked as ggui#1503). New in `@ggui-ai/protocol`: `runtimeBundlePlainTwin`, `runtimeBundleHashedNameSource`, and the `runtimeBundlePlainName` option on `gguiShellHtml`. The server logs `runtime_bundle_plain_served` for each serve of the unhashed name while the hashed name is mounted.\n- `@ggui-ai/mcp-server-handlers` exports `createRegisterGeneratedBlueprint`, an in-process entry (not an MCP tool) that registers engine-generated component code together with the authored source it was compiled from. It records the generation's own `llm` provenance (validated; no default) and hands the source to the blueprint registry, so a render that reuses the registration serves it through `ggui_get_render_source` and can be saved to a library. The public `ggui_ops_register_blueprint` input is unchanged and still takes no source, because it cannot verify that supplied source compiles to the supplied code. `attachAuthoredSource` adds that source to a registration made from compiled code alone, only when the given code is byte-for-byte what the registration serves, and has a dry-run mode that writes nothing. It reads its write back, and reports `lost_race` rather than `attached` when a concurrent rewrite of the row erases the hash. Alongside them, a blueprint registry wired with a code store but no durable blueprint store now writes the authored-source body its `sourceCodeHash` points at; before, the hash was recorded with nothing behind it.\n- `ggui_amend`, `ggui_update` and `ggui_render` log their session on the `tool_invoked` line, as the runtime tools do, so a reaction joins the tap that caused it by session instead of by app and time order. Amend and update log the session they app-scope-gated (the caller's input rides an error line as `claimedSessionId`); render logs the session id it minted, on a `rendered` and on a `failed` result, and none on a refusal (the id is the call's, not proof that a row backs it).\n- `GguiSessionPostSuccessArgs.generation` gains `effort?` — the named level the generation applied, as the engine reported it (`GenerationMetadata.effort`) — so a post-success hook reads the level that ran rather than a stored profile. Absent when no level was applied; `null` generation (a reuse or a failed generation) is unchanged.\n- The durable render identity record gains `blueprintIdentity?: 'ephemeral'`, set when the render served an ephemeral `blueprintId`; `RenderIdentityStore` implementations must persist it (a new conformance case), and a `ui://ggui/render/…` re-mint of such a render answers `BLUEPRINT_UNRESOLVABLE` naming the ephemeral identity instead of reporting a blueprint as gone.\n- The ephemeral `blueprintId` covers every fresh generation whose key is already bound to DIFFERENT code — a `forceCreate`, a render that took no index read (e.g. a deployment with no reuse negotiator), or one that lost a race — not only a `forceCreate`. When the regenerated code is byte-identical to the bound blueprint's, that blueprint's id is returned (`identity: 'existing'`).\n- The `ggui_render` result declares an optional `effort` member — the named effort level the generation that produced the render ran — and `GenerationMetadata` gains `effort?`, set by ui-gen when a named level's dials apply. Declared now, sent from the next release (the result's schema is closed at `tools/list`).\n- A `forceCreate` render at an occupied key now serves an ephemeral, content-addressed `blueprintId` of its own instead of the incumbent's; the `onBlueprintResolution` event's `identity` gains `'ephemeral'` (a self-hosted sink that switches on the three earlier values must add an `'ephemeral'` arm).\n- The durable blueprint record declares its minting engine's build (`Blueprint.build?: GeneratorBuild`, `generatorBuildSchema`, and `build?` on the portable blueprint; `GeneratorBuild` now lives in `@ggui-ai/protocol` and `@ggui-ai/mcp-server-core` re-exports it). Declared now, emitted next release: no server stamps a row yet, and a row without a stamp reads as `mintedBy: unknown`.\n- `ggui_consume` now sends the `nextStep` it declared in 0.24.0. When the drain returned events, the result carries a `ggui_amend({ sessionId })` hint to repaint the same card in place instead of rendering a new one, and its text leads with the same copy-paste example. It is absent on an empty drain and on a late drain from an expired render.\n- `MODELS.openrouter` lists `openai/gpt-6-sol`, `openai/gpt-6-luna` and `anthropic/claude-opus-5.5`, so they autocomplete as OpenRouter routes; every `<author>/<model>` string was already accepted by shape. They were listed after an OpenRouter tools smoke passed (3 of 3 tool calls returned per id with `tool_choice: required`, 0 errors); Opus 5.5 followed the two GPT-6 ids once its dotted id resolved to a price row.\n- A repaired handshake draft keeps what you declared on its actions. When a draft fails validation and the server repairs it (`suggestion.origin: \"synth\"`), the proposal now carries every member you declared on each action entry — `oneShot`, `confirm`, `nextStep`, `description`, `example`, `icon` — changing only what the findings name. Before, a repair rebuilt each action from its label and schema alone, so a card whose draft tripped any validation error was served without its `oneShot` and rendered with no one-shot guard, and nothing said so. Anything the repair cannot keep is named at its own path: a declared member the repaired contract cannot carry (a `nextStep` whose tool the repair did not re-declare) as a `REPAIR_MEMBER_DROPPED` finding at `actionSpec.<name>.<member>`, an action the repair renamed or removed as `REPAIR_ENTRY_DROPPED` at `actionSpec.<name>`, each with the gate's reason in its message; a member the gate refused on your draft is named by that `CTR_*` finding at the same path, and a salvaged subset names each cut with the gate's code at the cut path. The deterministic normalization tier, which never calls a model, keeps the same members; its allowed keys are now derived from the protocol's entry schemas instead of a hand-kept list, so a stream entry's `mode`, `replay`, `complete` and `example` survive it too.\n- The handshake suggestion's `blueprintMeta.matchedIntent`, declared in 0.24.0, is now sent. On a judged semantic hit from the app's own pool it carries the matched card's stored intent, at most 280 characters, so an agent can compare it with its own intent before accepting the reuse. It is absent on an exact-key hit, where no judge ran, and on a hit from a pool shared across apps.\n- `ggui_runtime_submit_action` now checks a dispatch against the card's `actionSpec`, as the live channel already did. An undeclared action, or a payload the declared schema rejects, is answered as a normal tool result, `{ ok: false, code: 'CONTRACT_VIOLATION', message, violations }`, and never reaches `ggui_consume`. A card that declares no `actionSpec` is not checked, on either path.\n- Hosts built on `@ggui-ai/agent-server` offer the model only the tools it may be offered. Each MCP tool's declared `_meta.ui.visibility` is now read, and a tool whose visibility lacks `\"model\"` (the six app-only `ggui_runtime_*` tools) is no longer declared as the agent's capability. `listModelVisibleTools({ url, bearer })` returns one server's model-visible tool names for your agent SDK's tool filter, and the Google ADK, OpenAI Agents and Claude Agent samples now use it. The Claude sample's default allowlist is derived the same way instead of kept by hand, and an explicit `allowedToolsByServer` still wins.\n- `@ggui-ai/ui-gen/check` exports `generateSampleProps`, the props a render check falls back to for a contract: each prop's `example`, then its `default`, then a value filled from its schema type.\n- A handshake can use a judge other than the LLM caller. `HandshakeDecisionAdapter` in `@ggui-ai/mcp-server-handlers/renders` takes an optional `resolveRerank(ctx)` that returns a `RerankPair` (a judge with the threshold it was measured on, now a named export), passed to the matcher as its `rerank` pair; without it, matching is unchanged. `@ggui-ai/negotiator` exports `RERANK_SYSTEM_PROMPT`, the match / no-match definitions the built-in judge asks, so another engine's judge can ask the same question.\n- A card now reads the host's answer to its wake-up message. When a tap needs a new agent turn, the card sends the host a `ui/message` and says it was sent to chat; if the host refuses that message in-band, the card now says so and asks the user to send a message to continue, instead of leaving a tap that silently did nothing. The refusal is recorded as a `doorbell.refused` event with the host's error code. The tap itself was never lost: it is already queued for the agent.\n- Text in a card that isn't wrapped in `<Text>` is now set at the theme's body size, `--ggui-font-size-base`, instead of the browser's 16px. On a theme whose body size is `1.1rem`, a plain paragraph now reads 17.6px, as `<Text>` already did. Every built-in theme's body size is 16px, so a card on a built-in theme looks the same. Cards that `@ggui-ai/mcp-apps-react` renders into your own page take the same rule, so their plain text follows the card's theme rather than your page's font size. `cssTokensForAppTheme` in `@ggui-ai/ui-gen` sets the same size on the judged page's body, so a visual judge reads text at the size a visitor sees.\n- `@ggui-ai/negotiator`'s `LLMCaller` has an optional `callStructuredMetered`, which returns `{ value, usage? }`: the tool input, with the call's token usage as the provider reported it. The rerank judge uses it when a caller has it, so `RerankDecision.tokenCost` carries real token counts. `tokenCost` is now optional. Absent means the call wasn't metered: the caller has no metered method, the provider reported no usage, or the call threw. `{ input: 0, output: 0 }` now means only that no provider call was made. Code that reads `tokenCost` directly must handle it being absent. Existing `LLMCaller` implementations compile unchanged; `TokenUsage` and `Metered` are exported.\n- `@ggui-ai/mcp-server`'s Anthropic caller implements `callStructuredMetered`, so a handshake your server judges with an Anthropic model reports the response's token usage as the decision's `tokenCost`. A response with no readable usage reports none, never zeros.\n- `@ggui-ai/mcp-server-handlers/renders` exports `generationInputsForHandshake(record, resolved)`: the input a `ggui_render` of a stored handshake hands its generator, minus the render's own `sessionId`. It comes with the steps it is built from, `storyForHandshake`, `generatorInputForStory`, `appGadgetsForContract` and `fetchGadgetTypes`. The render calls the same functions, so a server that starts that generation before the render arrives derives the same input. A render that sends an `override` or `infra` derives with them.\n- A handshake that carries a contract now searches your saved interfaces the way they were saved: its query embeds the contract summary with the intent, as every stored interface's vector does, instead of the intent alone. Its similarity scores therefore sit on a higher scale, so they get their own floor of 0.50 before the reuse judge. A handshake with no contract searches by intent alone as before and keeps the 0.2 floor. `minCosineForRerank` still overrides both. The 0.50 floor was chosen by a replay of 28 labelled request pairs through the real matcher under two embedding models, as the highest value that finds every match found today without adding a wrong reuse.\n- The with-guuey samples move to `@guuey/*` 0.30.0 and `@silverprotocol/*` 0.11.0 (AgJSON `1.0.0-draft.8`). `@ggui-ai/agent-server` and `@ggui-ai/mcp-apps-react` run their contract tests against the same core; it is a development dependency of both, so nothing they install changes. The AgJSON version check stays major-only, so earlier `1.0.0` drafts are still accepted. In core 0.11.0, AgJSON's reasoning `effort` is an open string (unrelated to ggui's effort levels, which stay a closed set), a memory record may carry `_meta`, and `prompt.blocked` may carry `reasonRaw`.\n- A card that has its component and still shows nothing now says so. After each commit, the runtime checks what it drew. The host's `component-empty` observability event gains three reasons: `rendered-nothing` (the component drew no element), `detached` (the tree landed in a part of the page that is no longer shown, because another mount took over the card), and `no-commit` (the card never finished drawing within five seconds, for example a component that suspends with no Suspense boundary). Each is reported once per blank, with a console line naming it. Before, these blanks were silent on every channel. If your host switches on the reason, treat an unknown one as a blank of unknown cause.\n- `@ggui-ai/wire` exports `useActionPending(actionName)`: `true` from the moment a card's action is sent until the agent's answer lands on the card (a props update, an amend or a new render for the session), or 20 seconds pass. A control can show it is working (`disabled`, `aria-busy`, \"Sending…\") instead of looking untouched while the agent reacts. The iframe runtime provides it, and a card's `import { useActionPending } from '@ggui-ai/wire'` resolves. A dispatch the contract validator refuses, or a repeat of a spent one-shot action, never reads as pending. `buildWireConfig` takes the matching `pendingInputsChanged` notice and an optional `actionPendingBoundMs`.\n- Generated cards use it. Every control that sends an action (a button, a select whose change sends, an input that sends on Enter) is disabled and marked `aria-busy` from the moment it sends until the agent's answer repaints the card, and a button shows a working label (\"Saving…\"). Only the control that sent the action looks busy; other controls of the same record may be disabled, and everything else stays enabled.\n- **Breaking, if your server bound them:** `@ggui-ai/mcp-server-handlers` no longer ships the orgs, connector-keys and coupon handler families (the `./ops-orgs`, `./ops-connector-keys` and `./ops-coupon` subpaths and their root exports), and `createGguiServer` no longer takes `opsOrgs`, `opsConnectorKeys` or `opsCoupon`. Organisations, wallets and user API keys are features of the hosted service, whose eleven tools keep their names and shapes there. `SINGLE_CALL_OPS` drops their three reads (`ggui_ops_list_orgs`, `ggui_ops_list_connector_keys`, `ggui_ops_get_org_balance`); a server that registers its own tools under those names passes them in `control.singleCallOps`. The apps family (`opsApps`) stays open, and its tool descriptions no longer describe the hosted service. A deployment that bound a removed family can stay on v0.24.0, or copy the handlers into its own code and register them through `createGguiServer`'s `handlers` option.\n- Text blocks carry an optional `phase`: `\"interim\"` marks narration between tool calls, such as \"Looking that up…\", and anything else or absent is answer text. The thread routes and the invoke client keep it.\n- A tap on a card no longer shows the visitor the action's internal name or its data. While it is sent, the card announces \"Sending…\" to assistive technology only, named by the action's declared `label` when it has one. When the host takes the wake-up message, nothing is drawn over the card. Only a tap the visitor must act on draws a notice: the host cannot receive a message, or refused it.\n- A card's live channel no longer reconnects forever when the server refuses its credential. `@ggui-ai/live-channel`'s `ChannelRegistry` and `WSTransport` take an optional `classifyFrame`, typed `FrameClassifier`, which returns a `FrameVerdict`. A frame you class `'accepted'` resets the reconnect budget, and one you class `'refused-terminal'` fails the WebSocket at once, so the registry moves to its next transport. Without it, behaviour is unchanged. The iframe runtime supplies one.\n- `@ggui-ai/mcp-apps-react-native`'s theme provider accepts React Native 0.82 and later, whose `useColorScheme()` can return `'unspecified'`. A consumer on RN 0.82+ no longer fails to typecheck, and `'unspecified'` counts as no system scheme: the `colorScheme` prop wins, and without it the provider uses `'light'`.\n- If you use `@ggui-ai/ui-gen`'s visual evaluator, scores on the inline (`xs`) canvas move. The card is captured at its natural height inside a stand-in for the host's frame, and content wider than the card fails the new critical dimension `canvas-overflow-x`. The criteria block is now asked in a report-only call of its own, so it never changes a score, and its cost is reported apart from the judgement's.\n\n## 2026-09-26 — v0.24.0: generate on your Claude login, and a card knows its one-shot action is spent\n\n- An exact contract match is served without waiting for the installed-blueprints walk, which used to hold up an app's first handshake after a server start. The walk still runs alongside it, and a marketplace-installed match, a miss and every similarity match wait for it, so an uninstalled interface is never served. `createInstalledBlueprintsProvider` takes an optional `onWalk` reporter that receives each walk's `scope`, `entries`, `evicted`, `listedRows` and `sweepMs`, and a reporter that throws is reported as a new `walk-report-threw` issue kind.\n- A component can read whether its once-only action is already spent. `useActionSpent(name)` in `@ggui-ai/wire` is `true` when the card's contract declares `name` `oneShot` and the action already fired, in this render or before a reload, so a consumed control can paint as submitted on first paint instead of looking live until a refused press. It flips when the card's own dispatch commits and when a later frame carries a spend recorded elsewhere. This release declares the hook; generated components are taught it in the next, because a card imports `@ggui-ai/wire` through its runtime's fixed export list and a name that list lacks fails at load. For hosts that build their own wiring, `buildWireConfig` now returns the config together with that `actionSpent` source and takes an optional `spentInputsChanged` notice.\n- `ggui serve --local-cli-login` is an opt-in way to generate without an API key: it uses the Claude login already on this machine. Your Claude plan's terms and usage limits apply, and it is about 2x slower than an API key (2.2x at the median). It covers Anthropic routes only, a real Anthropic key always wins, and it is refused with `--multi-user` and `--public-demo`, because one person's plan never serves other users. `@ggui-ai/ui-gen` exports `CLAUDE_CODE_LOGIN_CREDENTIAL`, the value a custom key resolver returns to select this path.\n- The handshake suggestion's `blueprintMeta` declares an optional `matchedIntent`: the stored purpose of the saved interface a judged cache hit proposes, at most 280 characters, so an agent can compare it with its own intent before accepting, and re-handshake with `forceCreate` if it names a different task. Declared in this release; servers start sending it one release later, so a host that cached this release's tool schema accepts it. The `intent` you send is stored with the interface and read again when later requests of this app are matched against it: describe the task, never the end user.\n- A handshake with `forceCreate` never proposes a saved interface. It skips the deployment pre-match and every find-similar probe, and goes straight to validating (and, if needed, repairing) the draft for a fresh build, which is what the paired `ggui_render` already did. Before, an agent that asked for a fresh build after an unwanted cache suggestion could be handed the same suggestion again.\n- `handshake.decided` telemetry no longer carries the decision's reason text, which could quote the agent's draft or name its contract's fields. `selectionReason` is replaced by `selectionReasonKind`, one of `curated-prematch`, `match-exact`, `match-semantic`, `verbatim`, `normalized`, `llm-repair`, `salvaged-subset`, `no-creds`, `negotiator-degraded`, `no-negotiator` or `unclassified`, and `selectionReasonHash`, the first 16 hex characters of the reason's SHA-256, so equal reasons still correlate. A `TelemetrySink` that read `selectionReason` must switch to the kind.\n- `@ggui-ai/mcp-server-handlers/renders`: a negotiator's decision takes an optional `reasonKind`, typed by the newly exported `HandshakeReasonKind`, and every negotiator the package ships sets it. **Breaking for direct callers of the decision builders:** `buildCacheReuseResult` takes a required fourth argument, the tier that hit (`'match-exact'` or `'match-semantic'`), and `buildCreateFallback` takes a required third argument, the fallback kind (`'no-creds'` or `'negotiator-degraded'`), placed before the optional request variance, so a variance passed third moves to fourth.\n- `ggui_runtime_pull`'s `tool_invoked` log line carries the `sessionId` of a successful pull, so the first and last pull of a session bound how long its card stayed mounted. A pull that fails, and every other tool's line, are unchanged.\n- A generator with the runtime probe wired and no evaluator runs the probe once after the coding turns (before, it did not run at all). That covers `createUiGenerator({ enableRuntimeRender: true })` without an `evaluation` config, and a direct `dispatchGeneration` call without one, where the probe is wired by default. The probe never blocks a card: a recoverable render crash gets one repair turn (one more coding-model call) and one re-probe, then the generation serves what it has, and any other result serves as it is. The generation's metadata reports the result in `runtimeProbe` and `evalMs`. `runtimeProbe` is a status-discriminated record: `status: 'ran'` carries a `verdict` (`'pass'`, or `'fail'` with the non-empty `failChecks` that failed, in the order the check kinds are declared); `'timed-out'` / `'infra-skipped'` / `'not-applicable'` carry no verdict; every status that reached the check carries `elapsedMs`, and `queuedMs` when the check waited for a probe slot. When a repair turn was taken, `repair` is `{ attempted: true, compiled: false }` (the pre-repair card is served and the top-level outcome is the probe that bought the turn) or `{ attempted: true, compiled: true, trigger }` (the repaired card is served, the top-level outcome is its re-probe, and `trigger` is the probe that bought the turn). The bounds are the deployment's inputs: `createUiGenerator({ runtimeRenderProbe: { timeoutMs, heapMb, maxConcurrent } })` builds one check per generator, `maxConcurrent` caps that generator's live probe workers (extra checks wait their turn in order), `createRuntimeRenderCheck(config)` builds a check directly, `dispatchGeneration` accepts one as `runtimeRender`, and `warmupRuntimeRenderWorker()` runs one real probe worker at boot and reports whether it could run, without throwing. Defaults unchanged: 30 s, 512 MB, no concurrency cap.\n- `@ggui-ai/negotiator` exports the rerank judge as a seam: `RerankJudge`, a function from a query and its candidates to a `RerankDecision`, and `llmRerankJudge(llm)`, today's LLM judge in that shape. `MatchBlueprintDeps` in `@ggui-ai/mcp-server-handlers/renders` takes an optional `rerank: { judge, threshold }` pair: the judge decides, its own `threshold` is the cut (`judgeThreshold` does not apply to it), and a pair wins over a bare `llm`. Without a pair, matching behaves exactly as before. **Breaking for direct readers of the decision:** `RerankDecision.reason` is now optional, because a judge may decide without prose, so code that reads it as a `string` needs a fallback.\n- The relay's contract-violation answer is declared: `ggui_runtime_submit_action` can answer `CONTRACT_VIOLATION` with the `violations` that failed the card's `actionSpec`, and `@ggui-ai/protocol` exports `contractViolationSchema`. Declared in this release; the relay starts enforcing it one release later, so a host that cached this release's tool schema accepts the answer.\n- `ggui_consume`'s result declares an optional `nextStep`, a `ggui_amend({ sessionId })` hint present when the drain returned events, so an agent answering a tap repaints the same card instead of rendering a new one. Declared in this release; servers start sending it one release later.\n- `ggui_consume` and `ggui_runtime_submit_action` log their session on the `tool_invoked` line, as `ggui_runtime_pull` does: `sessionId` plus counts and flags (`eventCount`, `status`, `timeoutS` and `aborted` on consume; `consumerPresent` on a committed dispatch), never gesture content.\n- `onBlueprintResolution` events carry their request context: optional `appId`, `handshakeId` and `sessionId`, plus `proposedBlueprintId` when the consumed handshake proposed a saved interface.\n- `@ggui-ai/mcp-server` exports `buildLlmCaller`, whose Anthropic calls take an optional `{ signal }`, so a deployment that builds its own handshake negotiator can bound them with a timeout. Without a signal, calls run as before.\n- `@ggui-ai/mcp-apps-react` and `@ggui-ai/mcp-apps-react-native` describe themselves as MCP Apps host helpers in their npm description, keywords (`mcp-apps`), README and docs.\n\n## 2026-09-25 — v0.23.0: a once-only action survives a reload, and ggui.json can clear what a deploy set\n\n- A spent once-only action stays spent after a reload. After a committed dispatch of a `oneShot` action, the built-in in-memory and SQLite session stores keep a record for that card, the `ai.ggui/render` slice lists the card's spent names as `spentOneShots`, and the runtime seeds its guard from that record. On a re-served card, the first gesture on an action the card already spent is stopped and traced as `one-shot-spent`, exactly like a second gesture on a live card. The record counts only on the card it was written on, and only for names that card still declares `oneShot`; a card replaced by `ggui_update` starts fresh. The runtime stops the gesture, and how the control looks afterwards is still up to the component. Hosts that build their own wiring can pass `getSpentOneShots` to `@ggui-ai/wire`'s `buildWireConfig`. A custom `GguiSessionStore` can implement the new optional `recordSpentOneShot`. A store without it keeps today's behaviour, and the server says so once at startup (`spent_one_shots_not_durable`). A malformed `spentOneShots` is dropped and reported to the host as `spent-one-shots-invalid`, never silently.\n- `ggui.json` accepts `\"theme\": null` and `\"generation\": null`. Locally they mean the same as leaving the field out: the shipped default tokens, and no generation route. On `ggui deploy` they clear the app's stored theme or generation route, and the deploy prints `theme cleared` / `generation cleared`. Leaving a field out still leaves the stored value untouched.\n- `@ggui-ai/protocol` adds the write form of an app theme. `appThemeWriteSchema` admits `null` on the optional members outside the attestation (`mode`, `name`, `frameless`, `fonts`, `imagery`), and `splitAppThemeWrite` separates those clears from the document to store, so a write door can clear one member without clearing the theme. Attested members (`cssVariables`, `keyframes`) and required ones never clear by `null`.\n- A font face in the app's stored theme that the card's CSP blocks is reported to the host as `font-face-blocked`, the same event a blocked host-announced font raises; it used to fail with no event. A face's origin is admitted when the card mounts, so a face added on a new origin while a card is open loads on that card's next mount.\n- An expanded card (`fit: 'fill'`) has one frame rhythm: a 16 px inset, first-level corners concentric with the panel's, and a `bleed` prop for an element that should meet the panel's edges. A hero band that opens the card bleeds on its own, so cards already served keep their band edge to edge.\n- The handshake's semantic match returns to its own similarity floor, 0.2. Version 0.22.0 raised it to 0.3 to match `ggui_search_blueprints`, but the handshake scores a request's intent alone against saved interfaces stored with their contract and intent. On that scale a real match (the same task, with the same contract) can score under 0.3, and 0.22.0 generated those fresh instead of reusing them. `ggui_search_blueprints` keeps 0.3. The two floors become one number again once the handshake scores a request the way it stores an interface.\n- A host that declares `ui-message-turn` in its `Ggui-Host-Capabilities` connection header, because it delivers a later gesture as the agent's next turn, gets no `nextStep` on `ggui_render`, so the agent ends its turn when the card paints instead of long-polling `ggui_consume`. `@ggui-ai/protocol` exports the header name, the token and `parseHostCapabilitiesHeader`. A host that declares nothing, or only tokens the server does not know, sees no change.\n- The conformance kit grades the read-plane-only posture, where a server withholds the `ai.ggui/render` slice from tool results. A new catalog, `runReadPlaneOnlyConformance` from `@ggui-ai/protocol-conformance/read-plane-only-conformance`, checks that such a result carries the render locator on both `structuredContent.resourceUri` and `_meta.ui.resourceUri`, with the same value, that it carries no slice and no `wsToken`, and that reading the locator mounts the view. The posture now has its own SPEC section, §7.10.6. The host-helper catalog's H2 check is stricter: a helper that advertises `serverResources` is now sent a `resources/read`, and refusing, dropping or re-shaping it fails where it used to pass. Helpers that advertise only `serverTools`, as both first-party helpers do, grade as before. SPEC §5.5.2 adds that emitters SHOULD send `MISSING_META_GGUI_BOOTSTRAP`, and that hosts SHOULD treat `BOOTSTRAP_META_MISSING` the same way.\n- The model registry's lineup, the models a picker shows first, gains Claude Opus 5.5 and GPT-6 Luna. Claude Opus 5 becomes legacy and stays selectable. The default model, Claude Haiku 4.5, does not change.\n- `ggui_ops_generate_blueprint` returns the full 64-character `codeHash`, the same key `ggui_ops_register_blueprint` returns for the same code and the only form a code store accepts. It used to return a 32-character prefix, so its code could not be written to a guarded code store.\n- `@ggui-ai/negotiator`'s `LLMCaller.callStructured` returns `Promise<unknown>` and no longer takes a type parameter: an implementation returns the tool input as the model produced it, and the caller parses it, as the rerank judge and contract synthesis already did. An implementation written as `callStructured<T>(…): Promise<T>` still satisfies the interface. A call that passes a type argument no longer compiles; drop it and validate the result.\n- `@ggui-ai/iframe-runtime` exports every observability event type from its package root. `OneShotUnenforceableEvent`, new in 0.22.0, and `ComponentEmptyEvent` were reachable only from the `./observability` subpath.\n- When the OAuth flow hands consent to an external consent page, that page is given only the name the client registered. For a client registered without a name, a `client_name` on the authorize link used to reach the page and show as the client's self-declared name. It is now dropped, and the client reads as an unnamed client, as it does on the built-in consent page.\n- Generation: a runtime probe that runs out of time is reported as `timed-out`, with its elapsed time and the host's load, never as a component crash. A slow host no longer tells the coding agent to fix a crash that did not happen. A patch the engine refuses before applying anything is reported to the coding agent as `PATCH_INVALID` and logged, instead of being counted as a failed self-check that never ran.\n\n## 2026-09-23 — v0.22.0: a once-only action holds without a live session, and the self-hosted card never waits in silence\n\n- A once-only action now holds even when the card paints without a live session: past the session token's lifetime, before the live channel connects, or on a host with no live channel. The `ai.ggui/render` slice carries the contract's `actionSpec` whole, on every transport, and the runtime's `oneShot` guard reads it from there. When a card has an action but no action spec reached it (for example, a render from an older server), its first dispatch posts `one-shot-unenforceable` to the host once. Before, the guard failed open without any signal. A spec the runtime can read only in part is reported, never dropped quietly: `action-spec-invalid` for a spec the write door would refuse, and `action-spec-member-stripped` for an entry member from a newer server. `@ggui-ai/wire`'s `buildWireConfig` takes an optional `onActionSpecAbsent` for hosts that build their own wiring.\n- The default MCP Apps shell a self-hosted server serves never waits in silence. If the host never sends the tool result, or never answers the view's `resources/read`, the card shows a failure with Retry after 30 seconds and posts `ggui:bootstrap-failed` with `MISSING_META_GGUI_BOOTSTRAP`; a failed read's cause rides in the message instead of being reported as `MALFORMED_BOOTSTRAP`. A host that never answers or refuses `ui/initialize` now gets `UI_INITIALIZE_FAILED` posted, not only a card. The reasons and the 30-second bound are the hosted runtime's.\n- Blueprint reuse is stricter: the handshake's semantic match now uses the same similarity floor as `ggui_search_blueprints` (0.3, was 0.2), so a short request that is the same task in fewer words may generate fresh instead of reusing a saved interface.\n- A saved interface whose intent is only a stand-in — an operator placeholder, an id, a manifest `name`, or a persona — is never proposed by semantic match; it is still reused when a request's contract matches exactly. A row a persistent store kept from an earlier version carries no such mark until it is registered again. To keep an installed blueprint eligible, give its manifest a `description`; an operator registration keeps it with a `seedPrompt`.\n- Every generation names the build that made it: `GenerationMetadata` gains an optional `build` (`GeneratorBuild` in `@ggui-ai/mcp-server-core` — an optional package `version`, an engine-defined `mode`, and content `digests`), so results can be grouped by the engine build that produced them. `@ggui-ai/ui-gen`'s `createUiGenerator` fills it on every generation, as its precise `UiGenBuild` (the design mode, and digests of its prompt and boilerplate templates); `createAdvancedUiGenerator` and any generator that reports no build leave it absent.\n- Under the hood: an app theme written through `ggui_ops_set_app_theme` that carries overlay variables this server does not know, such as ones a newer `@ggui-ai/design` projects, is stored verbatim and the names are logged, instead of refused, so a newer caller can write to an older server during a rolling upgrade (a theme that leaves a known variable uncovered is still refused). In generation, a component that ignores a declared prop gets one bounded feedback round instead of shipping, a declared chat card is composed as a card for the chat bubble, eval calls' prompt-cache tokens count toward `tokens.total`, a run the same-exchange guard ends says so on `GenerationResult.sameExchangeBreak`, and the Claude Code login child starts with an allowlisted environment instead of the parent's, so a provider key or switch in the parent's environment no longer reaches it.\n\n## 2026-09-23 — v0.21.0: three new models, and the tool-call guards they need\n\n- New models: `claude-opus-5-5`, `gpt-6-sol` and `gpt-6-luna` can be selected by name; no default moves. Models that refuse a forced tool call — Claude Opus 5.5 and Claude Fable 5.1 — are no longer sent one, in generation or in the blueprint judge, and the gpt-6 generation sends the token-cap field the OpenAI API expects.\n- OAuth: the authorization response names its issuer (RFC 9207 `iss`, advertised in the server metadata), and client registration is rate-limited per IP (10 registrations per 10 minutes).\n- Sandboxed frames can preflight the public read routes — the runtime bundle, component code and session reads — and get the same open access the reads themselves always had.\n- Generation and reuse: the visual judge checks the card at the size it is declared to render, prompt-cache reads are reported for Google and OpenAI routes, and an action's control shows its contract label verbatim. The blueprint judge now weighs only cached cards that clear the similarity floor (before, only the closest one had to), and `GGUI_CACHE_TRACE=scores` logs each reuse decision with its keys and scores but none of the request text.\n- A theme's motion tempo now reaches the card. Every design-system transition reads `--ggui-motion-duration-{fast,base,slow}` and `--ggui-motion-easing-{standard,emphasized,exit}`, so a theme that states `motion.duration` / `motion.easing` animates the card at its own tempo; a theme that states none animates at the shipped tempo, as before. (Recorded late: this entry first went out without it.)\n\n## 2026-09-19 — v0.20.0: a security fix to the OAuth registration door — upgrade if you run `--oauth`\n\n- **Security fix (ggui-ai/ggui `@ggui-ai/mcp-server`):** the Dynamic Client Registration door now shape-checks every `redirect_uris` entry before storing it, bounds `client_name`, and the consent page names who is asking and where the browser will be sent. Before this release a registration could carry a malformed or unintended redirect target through the door. Self-hosters running `ggui serve --oauth` should upgrade to `0.20.0`; the hosted service already has the fix (the hosted consent page named the client and destination from 2026-09-21).\n- The theme manifest is treated as a wire: a variable added to it in a newer release completes from what an older release's theme already carries, so a theme composed on the previous version is not refused at the door — and the previous release's manifest is pinned as the fixture that catches the next such growth.\n- Generation: form inputs are primed before the action-wiring probe runs, so a control that only enables once its field has a value is exercised rather than reported missing; the OpenAI adapters now report non-cached input and cache-read tokens separately, so a cost row reads what the provider actually billed.\n\n## 2026-09-17 — v0.19.0: a spent action stays spent, and the theme names what the card cannot read\n\n- A once-only action is spent for the life of the render, not just disabled on screen: a second gesture on a `oneShot` control is stopped by the runtime and traced as suppressed — it never reaches the agent and it is never dropped silently.\n- The theme says what it cannot do instead of skipping it: a document member the card's projector cannot read produces a named diagnostic rather than the same output as if it were absent, the members no card paints yet say so on their own type, and corner radius is now projected by role — a host's cards and its controls take two values, and the control primitives read theirs.\n- The stored theme can be read back in the shape a later write carries: beside the render-time read, a carry read returns exactly what was stored, so a host can reproduce a theme without re-deriving it.\n- Under the hood: a render names how it resolved its blueprint — what it looked for, whether it hit, and whether the registry deduplicated — an `override.contract` re-aims at its key the way `override.variance` already did, and the Google adapter hands its key to the model directly, so a stale `GOOGLE_GENAI_API_KEY` in the environment can no longer win over the key you configured.\n\n## 2026-09-16 — v0.18.0: a contract can say an action fires once, and the card can be dismissed\n\n- An action that should happen once is now declared, not guessed: an action entry can carry `oneShot`, the generated card disables that control after it fires, and the agent is bound by the contract rather than by reading labels. Everything not marked `oneShot` stays repeatable — a control is never guarded by accident — and `confirm` remains an advisory signal for the author, not a gate.\n- The card can be dismissed by the host: a new host-bound `ggui:dismiss` intent forwards the user's dismiss gesture to the host and never acts on it inside the card, so closing means whatever the host decides it means.\n- The doors are stricter and say why: a theme write that would destroy stored members it does not carry is refused, and the refusal names exactly which members would be lost (clearing is an explicit `theme: null`, never an omission); a stored contract or theme carrying a member from a later release is read with that member stripped and named, not silently dropped; and the generation profile has one read door for everything that reads it.\n- The host's type scale and rhythm now reach the card: `typeScale` moves the type scale and `rhythm.base` re-derives the spacing — carried on the wire since 0.17.0, painted from this release.\n\n## 2026-09-15 — v0.17.0: a theme can name its typeface and imagery, and the hosted card loads them\n\n- A theme declares its own typeface and imagery: `typography.faces` in the document (`https:` sources) rides the wire as `fonts`, alongside `imagery` slots for a brand mark, a hero image and a repeating pattern. The hosted card admits every declared origin in its content-security policy and inlines the `@font-face` rules in the page it serves, so a named family actually loads — and `ggui deploy` now carries the faces the document declares instead of dropping them.\n- Declared but not yet painted: the type-role scale, the bounded motion tempo, and the `scrim` overlay are carried on the wire and validated at the door, but no card wears them yet — the work that makes them visible is still in flight. Nothing breaks if you declare them today; nothing changes on screen either. The motion tempo became visible in v0.21.0.\n- A read door never drops a theme in silence. A theme this release refuses is reported to the embedding host with the reasons, and a theme carrying members a NEWER writer sent is kept, stripped, and reported with the member names — so a rolling deployment can see exactly what an older card could not read.\n- A card the host shows fullscreen fills the frame it was given: the frameless silhouette is a fit rule now, the fill is anchored on the frame rather than a parent, and a mounted list no longer inherits the browser's bullets and indent.\n- A long-held poll ends the moment its caller goes away, and a relay error counts toward backing off — a card whose host times out its own calls no longer re-fires held requests forever.\n- For anyone running the evaluators: the visual judge names the design tree it painted with, a terminal action the static probe cannot reach reads as not-measured rather than as a failure, and an evaluation round that throws reports the failure as its result instead of returning nothing.\n\n## 2026-09-10 — v0.16.0: theming revised — layering roles, one anchor per family, the app theme on the wire\n\n- Theming is revised (`draft-2026-09-10`): a `theme.json` states the six layering roles — `ground`/`onGround`, `container`/`onContainer`, `sunken`/`onSunken` — and one `500` anchor per colour family; the ramps, inks, containers, outlines and the type scale are derived. `font.size`, `font.lineHeight`, `motion.duration`, `motion.easing` and `$metadata.fontUrl` are refused, and `ggui serve` refuses to start on a theme file in the old shape — `ggui theme validate <path>` names the field.\n- An app's theme on the wire is its projection for both modes: `{ overlays: { light, dark }, overlayHash, mode?, name? }`. The embedding host owns the runtime mode — its announced mode wins and the app's `mode` is a default — and a theme the write door refuses answers `invalid_app_config` with one of four bodies (`uncovered`, `unknown`, `overlayHash: \"mismatch\"`, `refused: \"v1 shape\"`).\n- Fonts are declared as `typography.faces` (`https:` sources only); `ggui deploy` names families the hosted path does not deliver yet.\n- The runtime theme-registration tools (`ggui_ops_register_theme` / `ggui_ops_list_themes` / `ggui_ops_delete_theme`) are gone.\n- `ggui serve` stops cleanly on Ctrl-C / SIGTERM: exit code 0, nothing native on stderr. Before, with the local embedding model loaded, every stop ended in a `libc++abi … mutex lock failed` abort (SIGABRT). The boot log now also says when the model is ready (`[ggui:embedding] warm — local model loaded in …ms`), so a supervisor can tell semantic search is live. The drain is bounded: if a handle ever keeps the process alive past 5 s after shutdown, `ggui serve` names it on stderr and exits with the same code instead of hanging.\n- `ggui serve --multi-tenant` is now `ggui serve --multi-user`. The flag turns on per-user LLM-key management at `/settings` (each authenticated user manages their own keys; builder identities are rejected at that gate) — the old name described a party this server does not have. No alias: the old flag is refused as unknown.\n- The endpoint-level refusal's `data.refusal` carries `appId` — the app the refused endpoint serves, equal to the endpoint path's `{appId}` — so a repair loop keys on it instead of parsing the message. A server built against 0.15.0 sends the refusal without it.\n- `UNAUTHORIZED` moves from `-32001` to `-32007`: `-32001` is the MCP SDK client's own `RequestTimeout`, minted locally — a client reading the number could not tell a server's refusal from its own timeout, the class the `-32000` move closed. `-32001` is retired-reserved.\n- Two refusal codes lose the word \"tier\": `model_not_in_tier` → `model_not_allowed` (the caller picks a model the app is allowed to use — the one refusal an agent may act on itself) and `already_on_tier` → `subscription_unchanged` (the subscription requested is the one the app already holds). A server built against 0.15.0 still emits the old names; the 0.16.0 conformance kit refuses them.\n- A `PendingEventConsumer` adapter appends and drains the protocol's `PendingEvent` — `{id, envelope, createdAt}`, the envelope always the entry object; the `sequence` field, which nothing ever wrote or read, is gone. Every row is validated through the new `pendingEventSchema` at the store boundary: `append` refuses a malformed row before storing it (`PendingEventMalformedError`), and a drain never returns a row that fails the schema nor drops a well-formed sibling because one failed — the sqlite adapter refuses the drain whole and rolls back, a DynamoDB-style adapter quarantines the row with a `pending_event_malformed` log and delivers the rest, and `ggui_consume` reports a refused drain as a typed failure carrying `{events: [], status}` rather than a raw error. The bytes of a well-formed row are unchanged.\n- The server-level instructions and the tool descriptions now say the same thing, and the true thing, about the render response: a refusal is five keys (`{code, message, fix, retry, handshake: 'intact'}`); the retry rule follows the refusal's `retry` class (`after-fix` is yours only when `fixBy` is `caller`; `later` retries after its delay; `next-period` and `never` are not retried); a missing `handshakeId` is an input-validation error (-32602) while an unknown one fails as not found; a `rendered` or `failed` render consumes the handshake and a `refused` render or a recoverable validation error leaves it intact; a consume event carries seven keys (`type`, `sessionId`, and the five gesture fields); `ggui_consume` teaches end-your-turn on an empty drain instead of \"exit only when expired\"; the never-emitted `missing_props` code is gone from both, and `ggui_list_sessions` is described by the `hostName` + `hostSessionId` pair it actually takes. A contract test pins every one of these against the booted server's `tools/list`.\n- `@ggui-ai/wire`'s connection state splits into a read view and a claimed writer: the root barrel exports `connectionSource` (`{subscribe, getSnapshot}`, what `useRender().isConnected` reads) and nothing that writes; `connectionStore` / `ConnectionStore` are gone. The writer lives on `@ggui-ai/wire/internal` — `claimConnectionWriter(name)` — and a deployment's runtime claims it once, eagerly at boot; a second claim throws `ConnectionWriterConflictError` naming both parties — the store is one per document (anchored on `globalThis` under `Symbol.for('ai.ggui.wire/connection-store')`, shared by every copy of the module), so the same name twice really is two copies of the runtime in one document, and a foreign value at that slot is refused with `ConnectionStoreSlotError` rather than adopted or overwritten; a released writer that writes throws `ConnectionWriterReleasedError`. Generated component code cannot reach the writer: it is not on the barrel, the import rewriter refuses `@ggui-ai/wire/internal` at load time, and the generation gate refuses it before any code is emitted — one forbidden list, three doors.\n- A render denied by the per-app render-rate cap is a refusal like any other render-gate refusal — `outcome: 'refused'`, `refusal.code: 'app_rate_limited'`, `retry: 'later'` carrying the wait, handshake intact — instead of a thrown error whose text lost the code, the fix and the retry class.\n- `RateLimitedError` is gone from `@ggui-ai/mcp-server-core`: a limiter denial has no thrown form — the render handler returns the registry's `app_rate_limited` refusal; the `RateLimiter` seam (`RateLimitCheckInput`, `RateLimitDecision`) stays. Hosted GGUI's `RateLimitedError → 429` arm went with it, so no first-party server emits `-32013` any more.\n- A rate-limit decision can now say whose cap bound: `RateLimitDecision.scope` is `'app'` (the default when absent — existing limiters are unchanged) or `'issuer'`, and the render gate's refusal follows it — an issuer-cap denial is `issuer_rate_limited` and names the issuing identity, where before every denial said the app was over its cap.\n- All 32 published `@ggui-ai/*` packages moved to `0.16.0` in lockstep.\n\n## 2026-09-05 — v0.15.0: declarations that resolve everywhere, and a browser entry\n\n- Every published type declaration now carries explicit `.js` extensions on its relative imports, so `@ggui-ai/*` typechecks under TypeScript's `NodeNext` / `Node16` module resolution as well as `bundler`. Before this, a NodeNext project saw `no exported member` on names such as `PLATFORM_ERROR_CODES`.\n- `@ggui-ai/protocol/wire` is a browser entry: the same names, only the modules a browser validates. The iframe runtime and the React / React Native host helpers import it; the iframe bundle is 37 KB smaller.\n- Authorization refusals on the MCP endpoints answer JSON-RPC `-32001` (`UNAUTHORIZED`) instead of the generic `-32000`, so a client can tell a refusal from a dropped connection. A deployment's error mapper may attach JSON-RPC `data` — a structured reason — to such a refusal.\n- `ggui_consume`, `ggui_list_sessions` and `ggui_emit` declare their wire shapes from the protocol; `ggui_emit` now advertises the `seq` it always returned.\n- The per-app MCP endpoint answers GET and DELETE with `405` + `Allow: POST` instead of a text/html 404, so an optional SSE listener (Google ADK's) stops logging `Not Found` every turn.\n- If a host refuses the iframe's `ui/initialize`, the iframe runtime now records the boot as failed and keeps its relay closed, instead of forwarding gestures from a document that never connected.\n- All 32 published `@ggui-ai/*` packages moved to `0.15.0` in lockstep.\n\n## 2026-09-05 — v0.14.0: a refusal is an outcome, and Node 22 is the floor\n\n- A render can now be refused before anything is generated, and the refusal is its own outcome — not a failure dressed as one. `ggui.json` declares protocol `draft-2026-09-04`; the loader refuses a manifest whose stamp it does not support with `UPGRADE_REQUIRED`, and a dated migration doc walks the change.\n- `ggui_render` accepts a model route in either wire form (`provider:model` or `provider/model`), and LiteLLM aliases resolve in both.\n- Handlers are built with `defineHandler` — a handler's output type derives from its schema, so a result that omits or invents a key fails to compile rather than being silently stripped on the wire. Failed calls can now be observed with `postFailureHook`.\n- Rich text renders GFM pipe tables as tables.\n- Every `@ggui-ai/*` package now requires Node 22 or newer.\n- All 32 published `@ggui-ai/*` packages moved to `0.14.0` in lockstep.\n\n## 2026-08-31 — v0.13.0: calm chrome for launch day\n\n- The pre-render loading surface is neutral and scheme-aware now — a light ground in light mode, a dark ground in dark mode — instead of the fixed dark slab that flashed while components rendered. Themes still repaint it the moment they load, and embedders keep a one-variable override.\n- A branded working-state mark plays while a component renders: GGUI's four glyphs assemble, swap inks, and clear on a calm cycle. Serving deployments can replace it with their own mark (or disable it) via the shell assembler's new `loadingIndicator` option; it honors reduced-motion and retires the instant real content paints.\n- The generated-component feedback row is rebuilt and off by default: two theme-aware thumb icons (up / down) replace the old text buttons, and the row renders only for deployments that opt in via the runtime's `uiFeedback` boot option.\n- Package builds publish atomically — a dependency's compiled output is never observably half-present to concurrent tooling.\n- All 32 published `@ggui-ai/*` packages moved to `0.13.0` in lockstep.\n\n## 2026-08-24 — v0.12.0: the server tells the truth about itself\n\n- The MCP `serverInfo`, the MCP Apps `appInfo`, and `/ggui/health` now all report the real shipped release instead of placeholder versions — every connected agent sees which build it is actually talking to, and a new runtime constant keeps that identity locked to the published packages.\n- A registered theme now travels with the render: the server delivers the theme's full light/dark token ladders alongside the overlay, and the runtime installs the delivered ladder as the base — a registered theme looks the same everywhere, with no compiled-in copy to drift.\n- Theme registration gained guardrails: a coverage validator proves a theme defines every token the design system consumes, unknown theme ids are refused where they were typed, and a brand-shaped overlay gets a pointer to full registration instead of silently painting on defaults.\n- Solid-accent components (buttons, active tabs, checkbox marks) read their text color from the theme's on-accent tokens instead of hardcoded white, with a WCAG AA contrast sweep over every producible pairing now standing in the test suite.\n- Generated UIs are checked for prop sensitivity — a displayed value that ignores its prop is caught before it ships.\n- All 32 published `@ggui-ai/*` packages moved to `0.12.0` in lockstep.\n\n## 2026-08-21 — v0.11.0: rendered cards match the app around them\n\n- Host-announced color palettes now reach generated UI: the spec's `--color-*` variables translate onto GGUI's design tokens and layer beneath the app's own theme — the app's theme always wins, the host fills what it didn't set.\n- A per-app theme now selects the full base token set, not just an overlay: its color mode picks the right light/dark ladder, and a theme whose name matches a registered preset gets that preset as its base. Themes can also declare themselves `frameless` when the embedding app draws the card's outline itself.\n- The React Native host view no longer paints its own border, and it reports content-size changes through a new `onSizeChanged` callback so embedders can auto-size the card.\n- Tool-result listeners now register before the host handshake on every boot path, closing a timing gap on re-emitted results.\n- All 32 published `@ggui-ai/*` packages moved to `0.11.0` in lockstep.\n\n## 2026-08-19 — v0.10.0: renders validate against the schema the handshake promised\n\n- The handshake now returns the enforced props schema for a blueprint — plus its hash and profile — and every render is validated against that exact schema before it ships. What the agent was promised is what the view receives.\n- New protocol helpers for working with props schemas: build the enforced schema, canonicalize it to stable bytes, classify its profile, and validate data against it. Servers get a dedicated `props-schema-hash` entry point for cache keys.\n- The conformance kit gains a props-schema catalog — seven polyglot cases any implementation can run to prove its schema handling matches the spec.\n- All 32 published `@ggui-ai/*` packages moved to `0.10.0` in lockstep.\n\n## 2026-08-13 — v0.9.0: host helpers get their real name\n\n- **Breaking:** `@ggui-ai/react` is now `@ggui-ai/mcp-apps-react`, and `@ggui-ai/react-native` is now `@ggui-ai/mcp-apps-react-native`. Update your imports — the old package names are retired. The new names say what these packages are: MCP Apps host-helper libraries, not a GGUI SDK.\n- `GguiRender` and `useWebSocket` are removed from both packages. Rendering semantics live in the mounted view (iframe runtime), never in the helper.\n- First release of the React Native host helper: `@ggui-ai/mcp-apps-react-native`, with `<McpAppIframe>` as its front door.\n- All 32 published `@ggui-ai/*` packages moved to `0.9.0` in lockstep.\n\n## 2026-08-11 — hub.ggui.ai: browse the public registry\n\n- The registry browser is live at [hub.ggui.ai](https://hub.ggui.ai) — search published blueprints and gadgets, filter by the MCP tool or server they bind to, and see works-with chips and verified-publisher badges at a glance.\n\n## 2026-08-10 — v0.7.0: renders that outlive their cache\n\n- Reopening an old card from chat history now works even after its render was evicted from cache: `resources/read` re-mints the view from its stored blueprint and committed body, so old cards rehydrate instead of going stale.\n- Resource-read failures are typed against a closed error enum, so a host can tell \"gone\" from \"denied\" from \"malformed\" instead of guessing.\n- Hosted renders are kept indefinitely by default.\n- All `@ggui-ai/*` packages moved to `0.7.0` in lockstep.\n\n## 2026-08-08 — Host-capability awareness\n\n- The iframe runtime now reads the host's advertised capabilities at boot, instead of assuming every embedding host behaves the same way.\n- Hosts that don't relay `ui/message` back to the agent get an honest, one-time explanation in the UI instead of a gesture that silently goes nowhere.\n- Relay incapability is only latched after a confirmed failure — a host is never assumed broken just because it hasn't advertised a capability yet.\n\n## 2026-08-08 — Browser CORS support on the MCP plane\n\n- The MCP server now mounts a browser CORS layer with an allowlisted-origins model, so browser-based MCP clients can call it directly without a proxy.\n- Origin and Host validation is enforced on both the HTTP and WebSocket upgrade ingresses, closing a DNS-rebinding gap.\n- The CLI gained a `--browser-origin` flag (and `GGUI_BROWSER_ORIGINS` environment variable) to configure which origins are allowed.\n- The `/ggui/health` endpoint now surfaces the effective allowed origins for debugging.\n\n## 2026-08-07 — v0.6.3: rich text\n\n- UI components generated by GGUI can now carry rich-text descriptions — bold, italics, links, and inline code — instead of plain strings only.\n- The rich-text parser is now vendored directly in the design package rather than pulled in as an external dependency.\n- All `@ggui-ai/*` packages moved to `0.6.3` in lockstep.\n\n## Protocol baseline\n\nThe current protocol draft is `draft-2026-09-10`. See [Version policy](/protocol/version-policy/) for how draft versions relate to semver on `@ggui-ai/protocol`, and what changes would trigger the next draft bump."},{"path":"/cli.md","title":"ggui CLI","description":"Open command-line tool for the GGUI protocol — local dev, self-host, marketplace authoring, and ggui.ai cloud provisioning.","body":"---\ntitle: ggui CLI\ndescription: Open command-line tool for the GGUI protocol — local dev, self-host, marketplace authoring, and ggui.ai cloud provisioning.\n---\n\n`ggui` is the open CLI for the GGUI protocol, shipped as [`@ggui-ai/cli`](https://www.npmjs.com/package/@ggui-ai/cli). It does three jobs:\n\n1. **Local protocol dev + self-host** — `ggui dev` (iterate gadgets and blueprints against a local registry + dev hub), `ggui serve` (run a self-hosted personal-mode app), `ggui keys … --keys-file` (mint local bearers, no account), and `ggui export-pool` (share blueprints across deployments). Account-free; runs entirely on your machine. **Available now.**\n2. **Marketplace authoring** — `ggui gadget` / `ggui blueprint` author and publish marketplace artifacts; `ggui theme validate` checks DTCG theme files.\n3. **ggui.ai cloud provisioning** — `ggui login` / `keys` / `create` / `deploy` / `push` / `provider-key` provision apps and `ggui_user_*` connector keys against hosted ggui.\n\n:::note[GGUI vs guuey]\n`ggui` is the open CLI for the **GGUI protocol** (`@ggui-ai/cli`). The closed `guuey` CLI drives the **guuey.com** SaaS platform (agent deploys, logs, secrets) and has its own auth — none of the commands below configure it.\n:::\n\n## Install\n\n```bash\nnpm install -g @ggui-ai/cli\n# or\npnpm add -g @ggui-ai/cli\n```\n\n## Commands at a glance\n\n| Command             | Purpose                                                                                                                                                                                                                                                                                                                    |\n| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `ggui dev`          | Boot the local UI registry server, open the dev hub, optionally supervise an agent                                                                                                                                                                                                                                         |\n| `ggui serve`        | Run the open self-hosted personal-mode app (MCP server + supervised agent)                                                                                                                                                                                                                                                 |\n| `ggui keys`         | List / create / revoke connector keys. With `--keys-file <path>`, keys are minted into a local JSON store (no account) that `ggui serve --keys-file` reads — the self-host path. Without it, keys target the ggui.ai cloud. Also registers publisher Ed25519 public keys (see [`ggui keys register`](/cli/keys-register/)) |\n| `ggui theme`        | Validate a DTCG theme file against the protocol's theme schema (`theme validate <path>`)                                                                                                                                                                                                                                   |\n| `ggui export-pool`  | Export this deployment's reusable blueprints as a directory artifact, loadable elsewhere via `ggui serve --seed-pool <dir>`                                                                                                                                                                                                |\n| `ggui gadget`       | Author and consume marketplace gadgets (`create` / `publish` / `install` / `uninstall` / `search`)                                                                                                                                                                                                                         |\n| `ggui blueprint`    | Author UI blueprints for the marketplace (`create` / `publish` / `install` / `uninstall` / `search`)                                                                                                                                                                                                                       |\n| `ggui login`        | Sign into `api.ggui.ai` via the OAuth Device Authorization Grant                                                                                                                                                                                                                                                           |\n| `ggui logout`       | Discard the local `api.ggui.ai` session                                                                                                                                                                                                                                                                                    |\n| `ggui whoami`       | Print the authenticated user                                                                                                                                                                                                                                                                                               |\n| `ggui create`       | Provision a ggui.ai cloud app (`create app`)                                                                                                                                                                                                                                                                               |\n| `ggui deploy`       | Idempotent ggui.ai cloud provisioning; `--push-keys` also pushes provider keys (see [`ggui deploy`](/cli/deploy/))                                                                                                                                                                                                         |\n| `ggui push`         | Compile and upload local-pool blueprints to a ggui.ai cloud app                                                                                                                                                                                                                                                            |\n| `ggui provider-key` | Push an LLM provider key to a ggui.ai cloud app (`provider-key set`)                                                                                                                                                                                                                                                       |\n| `ggui --version`    | Print the installed `@ggui-ai/cli` version                                                                                                                                                                                                                                                                                 |\n\nRun `ggui <command> --help` for the per-command flag list. `ggui --help` prints the full surface.\n\n## Local dev & self-host\n\n`ggui dev` and `ggui serve` are the two ways to run the protocol locally:\n\n- **[`ggui dev`](/cli/dev/)** — inner-loop dev hub. Loads `ggui.ui.json` manifests, serves the local gadget + blueprint registry, and (with `--agent <entry>`) supervises a local agent runtime. Default `127.0.0.1:6780`. Use while iterating.\n- **[`ggui serve`](/cli/serve/)** — production-shaped self-host. Boots an MCP server with a supervised agent (`ggui.json#agent.entry`), ready to put behind your own auth on a public URL. Default `127.0.0.1:6781`; clients connect over WebSocket at `ws://127.0.0.1:6781/ws` (or `wss://<your-server>/ws` once tunneled). See [Reference deploys](/self-hosted/reference-deploys/) for Docker / Fly / Render manifests and the [self-hosted quickstart](/quickstart/self-hosted/) for the bootstrap.\n\nRun `ggui dev --help` or `ggui serve --help` for the full flag list.\n\n## Bearer keys — local first\n\nThe available-now path is account-free: `ggui keys list / create / revoke --keys-file <path>` mints bearer keys into a local JSON store — the same file format `ggui serve --keys-file` reads — so a locally minted bearer authenticates on the next serve boot. No account involved.\n\n### ggui.ai auth & keys\n\nHosted GGUI is live. Agent runtimes pointed at the universal MCP at `mcp.ggui.ai` authenticate with a `ggui_user_*` connector key, and `ggui login` signs the CLI into `api.ggui.ai` so you can mint and revoke those keys from the terminal — see [`ggui login`](/cli/login/) for the device-flow walkthrough plus `whoami`, `keys list / create / revoke`, and `logout`.\n\n## Pairing a client app\n\n`ggui` has no `pair` subcommand. To pair a client to a `ggui serve` instance, follow [Self-hosted pairing](/self-hosted/pairing/) — tunnel setup, QR handshake, and the AuthAdapter swap-in for production.\n\n## Configuration\n\nTwo environment variables control where the CLI talks and stores state:\n\n| Variable          | Default               | Purpose                                                                                                                                        |\n| ----------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |\n| `GGUI_API_URL`    | `https://api.ggui.ai` | Override the auth + key-management endpoint. Useful for sandbox / dev testing.                                                                 |\n| `GGUI_CONFIG_DIR` | `~/.ggui`             | Override the `~/.ggui` root (`auth.json`, BYOK `credentials.json`, embedding-model cache, publisher keypairs). Useful for isolated dev shells. |\n\n## Versioning\n\n```bash\nggui --version\n```\n\nVersions track the `@ggui-ai/cli` npm package. Protocol semantics are pinned to the `@ggui-ai/protocol` major version it bundles — see [Protocol version policy](/protocol/version-policy/) for what changes between major bumps."},{"path":"/cli/deploy.md","title":"ggui deploy","description":"Idempotent one-command provisioning — create a hosted GGUI app, mint its connector key, upload blueprints and config, and wire the endpoint into .env.local.","body":"---\ntitle: ggui deploy\ndescription: Idempotent one-command provisioning — create a hosted GGUI app, mint its connector key, upload blueprints and config, and wire the endpoint into .env.local.\n---\n\n`ggui deploy` provisions and wires a hosted GGUI app for the current project in one pass: sign in, create the cloud app, mint a connector key, upload your local blueprint pool and app config, and write the endpoint into `.env.local`. It is the command form of everything the [dev-loop crossover](/build/dev-loop/) does by hand.\n\nThe command is **idempotent**. Each step is skipped when its gate is already satisfied, so re-running after a partial failure resumes from the first unsatisfied step instead of duplicating work.\n\n## Quick start\n\nFrom a directory containing (or under) a `ggui.json`:\n\n```bash\nggui deploy\n```\n\nOn completion it prints the app's MCP endpoint and where the bearer landed:\n\n```text\n  ggui deploy complete.\n\n  App MCP endpoint:  https://mcp.ggui.ai/apps/<appId>\n  Bearer key:        in .env.local (GGUI_MCP_BEARER)\n```\n\nYour agent picks up the hosted endpoint on its next start — `.env.local` now carries both `GGUI_MCP_URL` and `GGUI_MCP_BEARER`, the same two variables the [local ↔ hosted swap](/build/dev-loop/) is built on.\n\nWithout a `ggui.json` anywhere up the directory tree, the command exits 1 before doing anything — it needs the manifest both to persist the `appId` and to know what to push.\n\n## The steps\n\nSteps run in this order. The first three are gated on current state; the rest always run:\n\n| Step          | Runs when                               | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                         |\n| ------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `login`       | no valid session in `~/.ggui/auth.json` | Device-flow sign-in to `api.ggui.ai` — same flow as [`ggui login`](/cli/login/).                                                                                                                                                                                                                                                                                                                                                                                     |\n| `create-app`  | `ggui.json` has no `appId`              | `POST /v1/apps`, then persists the new `appId` into `ggui.json`. If the app was created but `ggui.json` could not be written, the command stops loudly with the id to add by hand — re-running without it would create a duplicate app.                                                                                                                                                                                                                              |\n| `mint-key`    | `.env.local` has no `GGUI_MCP_BEARER`   | Creates a connector key (named `ggui deploy`) and writes its one-time-reveal secret into `.env.local`. The secret is shown once; only the prefix is retrievable later on [the connector keys page](/hosted/connector-keys/).                                                                                                                                                                                                                                         |\n| `push`        | always                                  | Compiles the local blueprint pool and uploads it to the app — the same operation as `ggui push --app <appId>`. Artifact ids are deterministic (`contractHash-variantKey`), so re-pushing the same pool is a no-op server-side.                                                                                                                                                                                                                                       |\n| `push-config` | always                                  | Pushes the app config declared in `ggui.json`: the gadget catalog, `app.publicEnv` (keys must match `GGUI_PUBLIC_APP_<NAME>`), the `generation` block (`model`, `keySource`) and the `theme`. Leaving `generation` or `theme` out leaves the app's stored value untouched. Setting it to `null` clears the stored value, and the step prints `generation cleared` / `theme cleared`. Loopback bundle URLs are rejected — a hosted app cannot fetch from `127.0.0.1`. |\n| `push-keys`   | only with `--push-keys`                 | Pushes your LLM provider API key to the app — see [Provider keys](#provider-keys-push-keys) below.                                                                                                                                                                                                                                                                                                                                                                   |\n| `wire-env`    | always                                  | Writes `GGUI_MCP_URL` into `.env.local` — unless one is already set, which is **never clobbered**.                                                                                                                                                                                                                                                                                                                                                                   |\n\n`.env.local` lives next to `ggui.json` (not necessarily the invocation directory). Both files are edited in place; other keys and comments are preserved.\n\n## The endpoint it writes\n\nThe per-app hosted endpoint is the **bare** per-app URL:\n\n```text\nhttps://mcp.ggui.ai/apps/<appId>\n```\n\nThere is no `/mcp` suffix — that suffix is the local [`ggui serve`](/cli/serve/) convention, and appending it to a hosted app URL produces a 404. Writing the correct shape automatically is the practical reason to prefer `ggui deploy` over hand-editing `.env.local`; the trap is documented in [the dev loop](/build/dev-loop/).\n\nTwo details of `wire-env`:\n\n- **First deploy** — the URL comes verbatim from the `connectUrl` the backend returned at `create-app`, so it is correct for whichever environment `GGUI_API_URL` pointed at.\n- **Re-deploys** — an existing `GGUI_MCP_URL` is left untouched, whatever it points at. Delete the line and re-run if you want it rewritten.\n\n## Provider keys (`--push-keys`)\n\nApps with `generation.keySource: \"own\"` render on **your** model account instead of drawing down GGUI credits — but only once the app actually holds your provider key. `--push-keys` adds that step to the deploy:\n\n```bash\nggui deploy --push-keys\n```\n\nThe provider is derived from `ggui.json#generation.model`, and the key is read from that provider's environment variable:\n\n| Provider     | Environment variable                              |\n| ------------ | ------------------------------------------------- |\n| `anthropic`  | `ANTHROPIC_API_KEY`                               |\n| `openai`     | `OPENAI_API_KEY`                                  |\n| `google`     | `GOOGLE_API_KEY` (falls back to `GEMINI_API_KEY`) |\n| `openrouter` | `OPENROUTER_API_KEY`                              |\n\n`bedrock` routes are rejected — IAM-only, no plaintext key to push. To name the provider explicitly instead of deriving it from the model string, call the underlying command directly: `ggui provider-key set --app <appId> --provider <name>`.\n\nIf `ggui.json` declares `keySource: \"own\"` and you deploy **without** `--push-keys`, the command still succeeds but ends with a warning: cloud renders will reject until a key is pushed. See [Credits, billing & BYOK](/hosted/billing/) for how the two key sources are charged.\n\n## What it does not do\n\n`ggui deploy` provisions the **GGUI side only** — the interface layer. Hosting the agent itself (the process that talks to your users and calls GGUI) is a separate concern: run it yourself, or use a hosting platform such as [guuey](/agentic-app-builders/). Nothing in this command deploys, restarts, or configures an agent runtime.\n\n## Flags\n\n```text\nggui deploy [--push-keys]\n```\n\n| Flag          | Purpose                                                                                        |\n| ------------- | ---------------------------------------------------------------------------------------------- |\n| `--push-keys` | Insert the `push-keys` step: push the provider API key from your environment to the cloud app. |\n\n`GGUI_API_URL` (see [CLI configuration](/cli/#configuration)) controls which backend the deploy targets; the default is production `api.ggui.ai`.\n\n## See also\n\n- [`ggui` CLI overview](/cli/) — the full command surface, including standalone `ggui push` and `ggui provider-key`.\n- [The dev loop](/build/dev-loop/) — iterate locally, then cross over to hosted with the two-variable swap this command writes.\n- [`ggui login`](/cli/login/) — the device-flow sign-in `deploy` runs when needed.\n- [Connector keys](/hosted/connector-keys/) — what the minted `ggui_user_*` key is and how to revoke it.\n- [Blueprints & the cache](/concepts/blueprints-and-the-cache/) — why uploading a matured local pool makes a fresh hosted app fast on day one."},{"path":"/cli/dev.md","title":"ggui dev","description":"Inner-loop dev hub — local blueprint registry, devtools console, optional agent supervision, optional managed tunnel.","body":"---\ntitle: ggui dev\ndescription: Inner-loop dev hub — local blueprint registry, devtools console, optional agent supervision, optional managed tunnel.\n---\n\n`ggui dev` is the developer-facing inner loop. It loads `ggui.ui.json` blueprint manifests, serves the local registry + dev hub at `127.0.0.1:6780`, and (with `--agent <entry>`) supervises a local agent runtime in the same shell. Distinct from [`ggui serve`](/cli/serve/), the production-shaped self-host counterpart.\n\n## Quick start\n\n```bash\nggui dev\n```\n\nBinds `127.0.0.1:6780`, indexes any `ggui.ui.json` blueprints declared in `ggui.json#blueprints.include`, and auto-opens the dev hub in your browser.\n\nTo iterate against a local agent runtime in the same shell:\n\n```bash\nggui dev --agent ./agent.ts\n```\n\n## What you get\n\nPort 6780 hosts the local blueprint registry + dev hub. The dev server exposes:\n\n| Path                                  | What it serves                                                                       |\n| ------------------------------------- | ------------------------------------------------------------------------------------ |\n| `/hub`                                | The dev dashboard (no auth — same-origin XHRs embed the bearer for everything else). |\n| `/hub/preview?ui=<id>`                | Iframe-mountable preview shell for one indexed blueprint.                            |\n| `/health`                             | Liveness probe (no auth).                                                            |\n| `/uis`, `/uis/:id`, `/uis/:id/bundle` | Discovered `ggui.ui.json` blueprints (Bearer auth).                                  |\n| `/events`                             | Server-sent events for live reload (Bearer auth).                                    |\n| `/runtime/status`, `/runtime/events`  | Mounted when `--agent <entry>` is set (Bearer auth).                                 |\n\nAll non-`/hub` endpoints require `Authorization: Bearer <token>`. The token is taken from `GGUI_DEV_TOKEN` if set; otherwise a random one is generated and printed (with an `export` hint) on the boot banner.\n\n`ggui dev` also sets `GGUI_MODE=dev` in the process env. The dev stack itself does not run an MCP server — that's [`ggui serve`](/cli/serve/)'s job — but a supervised `--agent` that composes `@ggui-ai/mcp-server` inherits the env and mounts its own `/devtools/*` console namespace.\n\n## vs `ggui serve`\n\n| Concern           | `ggui dev`                                             | `ggui serve`                                                                     |\n| ----------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------- |\n| Audience          | Developer iterating on UIs                             | Operator running a self-hosted instance                                          |\n| Default port      | `6780`                                                 | `6781`                                                                           |\n| Default mode      | `GGUI_MODE=dev` (mounts `/devtools/*`)                 | Production-shaped (no devtools)                                                  |\n| Agent supervision | Opt-in via `--agent <entry>`                           | Default-on, sourced from `ggui.json#agent.entry`                                 |\n| Tunnel            | Opt-in via `--tunnel` (provider seam; none bundled)    | Bring your own (`cloudflared` etc.) + set `--public-base-url`                    |\n| Auth posture      | Loopback bind + single bearer token (`GGUI_DEV_TOKEN`) | Strict-auth pairing by default; opt-down via `--dev-allow-all` / `--public-demo` |\n\nBoth can run side-by-side without colliding (different ports).\n\n## Flags\n\n```text\nggui dev [options]\n```\n\n| Flag              | Default     | Purpose                                                                                                                                 |\n| ----------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------- |\n| `--port <n>`      | `6780`      | Bind port. `0` = OS-assigned.                                                                                                           |\n| `--host <addr>`   | `127.0.0.1` | Bind host. Loopback only by default.                                                                                                    |\n| `--no-serve`      | off         | Load + discover and exit without binding. Useful for one-shot manifest validation / discovery dry-run.                                  |\n| `--no-open`       | off         | Skip auto-opening the browser at the hub URL. Implied by non-TTY stdout, `BROWSER=none`, or `CI=1`.                                     |\n| `--agent <entry>` | none        | Supervise a local agent runtime. See [Agent supervision](#agent-supervision) for extension routing.                                     |\n| `--tunnel`        | off         | Opt into managed mode — open a managed tunnel above the local stack and print the remote URL. See [Managed mode](#managed-mode-tunnel). |\n\n## Agent supervision\n\n`--agent <entry>` points at the agent file you're iterating on. Extension routing decides how it's spawned:\n\n| Extension             | Spawn                       | Notes                                                        |\n| --------------------- | --------------------------- | ------------------------------------------------------------ |\n| `.js`, `.mjs`, `.cjs` | `node <entry>`              | Plain Node                                                   |\n| `.ts`, `.tsx`, `.mts` | `node --import=tsx <entry>` | `tsx` must be resolvable in your project (`pnpm add -D tsx`) |\n\nThe dev-stack picks the agent's port and forwards it via `PORT` env unless `--tunnel` is also set, in which case the CLI pre-allocates a free port and hands it down so the tunnel can forward inbound traffic to it.\n\nBad `--agent` paths fail before the socket binds — the CLI validates the command mapping and exits 1 with a remediation hint.\n\n## Managed mode tunnel\n\nWith `--tunnel`, once the local stack is listening `ggui dev` asks a `TunnelProvider` to open a managed tunnel above the host and prints the remote URL beside the local hub URL. The dev loop runs unchanged whether the tunnel resolves or not.\n\nProvider discovery reads `GGUI_TUNNEL_PROVIDER` — a module specifier exporting `createTunnelProvider()`. No provider is bundled; without the env var the banner prints `tunnel skipped: no tunnel provider configured` and local dev runs unchanged. Real providers (`cloudflared` bindings, `ngrok`) plug into this seam without changing the CLI surface.\n\nFor a known-working public URL today, run `cloudflared tunnel --url http://localhost:6780` (or your provider of choice) in a sibling shell, then point claude.ai or your MCP client at the printed URL. For the production-shaped equivalent on `ggui serve`, see [`ggui serve` → Recommended setups](/cli/serve/#recommended-setups).\n\n## Common workflows\n\n**Iterate on a blueprint manifest:**\n\n```bash\nggui dev\n# edit packages/<your-app>/ggui.ui.json\n# refresh the hub — the registry re-indexes on every load\n```\n\n**Iterate on an agent + blueprints together:**\n\n```bash\nggui dev --agent ./agent.ts\n# edit agent.ts → the supervised runtime restarts on file change (when the runtime supports it)\n# edit ggui.ui.json → the registry re-indexes\n```\n\n**Run a second `ggui dev` alongside the first (6780 is taken):**\n\n```bash\nggui dev --port 0\n# 0 = OS-assigned; the actual port prints in the boot banner.\n# Pass `--port 6790` (or any free integer) if you need a stable URL.\n```\n\n**Validate manifests without binding a socket:**\n\n```bash\nggui dev --no-serve\n# loads + discovers + exits non-zero on any malformed ggui.ui.json\n# good in CI for catching manifest regressions\n```\n\n## See also\n\n- [`ggui` CLI overview](/cli/) — the full command surface.\n- [`ggui serve`](/cli/serve/) — production-shaped self-host counterpart.\n- [Self-hosted quickstart](/quickstart/self-hosted/) — the bootstrap walkthrough.\n- [Glossary](/glossary/) — `gadget` / `tool` / `blueprint` definitions."},{"path":"/cli/keys-register.md","title":"ggui keys register","description":"Register a publisher's Ed25519 public key with the ggui.ai marketplace registry so signed gadget and blueprint publishes validate.","body":"---\ntitle: ggui keys register\ndescription: Register a publisher's Ed25519 public key with the ggui.ai marketplace registry so signed gadget and blueprint publishes validate.\n---\n\n_Hosted surface — part of [hosted GGUI](/hosted/marketplace/)'s marketplace registry._\n\n`ggui keys register` ships your **publisher** Ed25519 public key to the marketplace registry's `POST /author-keys` endpoint. After this, every subsequent `ggui gadget publish` / `ggui blueprint publish` whose bundle is signed by the matching private key validates against the registry's stored row.\n\n:::note[Publisher key vs. connector key]\nThe `keys list / create / revoke` subcommands manage **connector keys** (`ggui_user_*`) that an agent runtime uses to authenticate to `mcp.ggui.ai` as a CLI bearer. `keys register` manages a different keypair entirely: a per-scope **publisher key** that signs marketplace artifacts. Different audience, different lifecycle — see [`ggui login`](/cli/login/) for the connector-key flow.\n:::\n\n## When to use it\n\nYou run `ggui keys register` **after** `ggui gadget publish` (or `ggui blueprint publish`) has auto-generated a keypair on disk for a given scope. The publish flow does the keypair generation automatically on first run, then signs the artifact, then POSTs to `/publish`. The registry rejects a signed publish whose `publicKeyId` isn't already registered for the caller's identity — that's the moment to run this command.\n\nThe typical bootstrap sequence for a new publisher:\n\n```bash\n# 1. First publish under @my-org auto-generates\n#    ~/.ggui/keys/@my-org/{private,public}.key and signs the bundle.\nggui gadget publish\n# → Error: unknown_key — public key not registered for this publisher.\n\n# 2. Register the public half with the registry.\nggui keys register --scope @my-org\n\n# 3. Re-run the publish. The signature now verifies.\nggui gadget publish\n```\n\nAfter step 2, every future publish under `@my-org` from this machine (and any other machine you copy `~/.ggui/keys/@my-org/private.key` to) validates without a second `register` call.\n\n## Usage\n\n```text\nggui keys register --scope <@scope> [--label <name>] [--registry <url>] [--auth=bearer [--token <token>]]\n```\n\n| Flag            | Required | Purpose                                                                                                                                                                                                                                                                               |\n| --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `--scope`       | yes      | npm scope the key was generated under, prefixed with `@` (e.g. `--scope @my-org`). The CLI reads `~/.ggui/keys/<scope>/public.key` — must already exist (see \"When to use it\" above).                                                                                                 |\n| `--label`       | no       | Display name stored on the registered key (e.g. `--label \"ci laptop\"`) so a later audit can tell two keys apart. An empty or whitespace-only value is a usage error, never a silent no-label register.                                                                                |\n| `--registry`    | no       | Override the registry URL. Same resolution chain as `ggui gadget publish`: `--registry` flag, then `GGUI_REGISTRY` env var, then `ggui.json#registry` walking up from CWD. No hard-coded default — pick a registry deliberately so a typo doesn't accidentally register against prod. |\n| `--auth=bearer` | no       | Send an explicit bearer token instead of the stored `ggui login` session — for self-hosted registries that authenticate with a static publish token. Pair with `--token <token>` or set `GGUI_REGISTRY_TOKEN`. Same flags the publish verbs take.                                     |\n| `--token`       | no       | The bearer token for `--auth=bearer` (overrides `GGUI_REGISTRY_TOKEN`).                                                                                                                                                                                                               |\n\n## Auth\n\n`ggui keys register` uses your stored **`ggui login` session** by default — the same credential `ggui gadget publish` sends to `/publish`, and the one the hosted registry's `/author-keys` route authenticates (see [`Marketplace § Auth`](/hosted/marketplace/#auth)). The CLI reads `~/.ggui/auth.json`, refreshes the access token automatically when it has expired, and sends it as `Authorization: Bearer <token>` — the only identity surface; the request body carries only `publicKeyBase64`, never the caller's user id. The server derives the publisher subject from the verified credential and the `keyId` from the raw public-key bytes.\n\nSelf-hosted operators running their own [`@ggui-ai/registry-server`](/sdk/self-hosted-registry/) deployments pass `--auth=bearer --token <token>` (or set `GGUI_REGISTRY_TOKEN`) — the same escape hatch the publish flow takes (see [`ggui marketplace`](/hosted/marketplace/#auth) for the parallel).\n\n## Output\n\nSuccess — first-write (HTTP 201):\n\n```bash\n$ ggui keys register --scope @my-org\nRegistered publisher key for @my-org.\n  registry: https://registry.ggui.ai\n  subject:  <your-user-id>\n  keyId:    a1b2c3d4e5f60718\n```\n\nIdempotent re-register (HTTP 200) — same public-key bytes for `(subject, keyId)` already on file:\n\n```bash\n$ ggui keys register --scope @my-org\nAlready registered publisher key for @my-org.\n  registry: https://registry.ggui.ai\n  subject:  <your-user-id>\n  keyId:    a1b2c3d4e5f60718\n```\n\nBoth exit `0`. Safe to run unconditionally in CI bootstrap scripts.\n\n## Errors and exit codes\n\n| Code                  | Exit | Meaning                                                                                                                                                                                                                                                                   |\n| --------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `no-registry`         | 1    | No registry URL resolved. Pass `--registry`, set `GGUI_REGISTRY`, or add `registry` to `ggui.json`.                                                                                                                                                                       |\n| `invalid-registry`    | 1    | Resolved URL is malformed.                                                                                                                                                                                                                                                |\n| `no-keypair`          | 1    | No `~/.ggui/keys/<scope>/public.key` on disk. Run `ggui gadget publish` or `ggui blueprint publish` under this scope first — first-publish generates the keypair as a side-effect.                                                                                        |\n| `auth_failed`         | 1    | The stored login session expired (or its refresh was rejected). Run `ggui login` again.                                                                                                                                                                                   |\n| `auth_config_missing` | 1    | No stored login session (`~/.ggui/auth.json` missing or unreadable) — run `ggui login` first. Or `--auth=bearer` was passed without `--token` / `GGUI_REGISTRY_TOKEN`.                                                                                                    |\n| `network-error`       | 1    | `fetch` threw — DNS, TLS, or connection refused.                                                                                                                                                                                                                          |\n| `unauthorized`        | 1    | Registry rejected the bearer credential (HTTP 401). Re-run `ggui login` (the session may have been revoked) — or, for self-hosted registries, check the `--auth=bearer` token.                                                                                            |\n| `invalid_request`     | 1    | Registry refused the body (HTTP 400). The public-key file is corrupted or the wrong length — regenerate by deleting `~/.ggui/keys/<scope>/` and re-running `ggui gadget publish`.                                                                                         |\n| `key_conflict`        | 3    | Registry holds a different public key for the same `(subject, keyId)` tuple (HTTP 409). Vanishingly rare — a 64-bit `keyId` SHA-256 truncation collision OR a stale row from a previous owner. Exit `3` is distinct so scripts can detect it without parsing the message. |\n| `http-error`          | 1    | Any other non-2xx response (typically 5xx). Check the message for the registry's error string; retry transient failures.                                                                                                                                                  |\n| `bad-response`        | 1    | Registry returned a 2xx with a malformed body, or any status with invalid JSON. Almost always a registry-side bug — re-run; if persistent, the registry is misconfigured.                                                                                                 |\n\nThe structured `error` field of the registry's response body (closed enum: `unauthorized` / `invalid_request` / `key_conflict` / `server_error`) is preferred over status-code mapping when the response carries a well-formed body — `key_conflict` returned with a non-409 status still surfaces as `key_conflict` on the CLI side.\n\n## Trust model\n\nThe on-disk private key under `~/.ggui/keys/<scope>/private.key` is mode `0o600` — treat it like a long-lived password. Copying it between machines lets you publish from CI without re-registering. Losing it means generating a new keypair under the same scope: when the new public key gets registered, both old + new keys are valid (publish flow pins the signing key onto each `ArtifactVersionRow` at publish time, so historical versions still verify under the previous key). To rotate out the old key entirely, register the new one + remove the old row server-side (operator-only).\n\nSee [`Marketplace § Trust model`](/hosted/marketplace/#trust-model) for the full per-author-key + per-version pinning design and the install-time two-leg verification (SHA-384 + Ed25519)."},{"path":"/cli/login.md","title":"ggui login","description":"Sign the @ggui-ai/cli into ggui.ai via OAuth 2.0 Device Authorization Grant to manage hosted connector keys.","body":"---\ntitle: ggui login\ndescription: Sign the @ggui-ai/cli into ggui.ai via OAuth 2.0 Device Authorization Grant to manage hosted connector keys.\n---\n\n`ggui login` signs the open `@ggui-ai/cli` into `api.ggui.ai` so you can manage `ggui_user_*` connector keys from the terminal — list, mint, and revoke — without leaving your shell.\n\nIt uses the [OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628): the CLI prints a URL and a short code, you approve in any browser (even on a different machine), and tokens land on disk once approval completes.\n\n:::note[GGUI (protocol) vs guuey (platform)]\nThis command targets the **open `ggui` protocol** CLI (`@ggui-ai/cli`) and the universal hosted MCP at `mcp.ggui.ai`. The closed [`guuey.com`](https://guuey.com) agent-hosting platform has its own CLI and auth — not configured by this flow.\n:::\n\n## Install\n\n```bash\nnpm install -g @ggui-ai/cli\n# or\npnpm add -g @ggui-ai/cli\n```\n\n## Sign in\n\n```bash\n$ ggui login\nEndpoint: https://api.ggui.ai (default)\n\nOpen this URL in your browser to approve:\n  https://console.ggui.ai/cli-confirm/ABCD-EFGH\n\nVerification code: ABCD-EFGH\n(Confirm this matches what the browser shows.)\n\nWaiting for approval…\nSigned in. Tokens saved to ~/.ggui/auth.json.\nTry `ggui whoami` or `ggui keys list`.\n```\n\nUnder the hood:\n\n1. **Device code request** — the CLI POSTs `/v1/auth/device` and prints the user-readable code plus URL.\n2. **Browser approval** — `console.ggui.ai/cli-confirm/<code>` shows the same code, your account, and an `Approve` button. Confirm the codes match, then approve.\n3. **CLI polls `/v1/auth/poll`** every few seconds until the server returns tokens.\n4. **Tokens land on disk** — `~/.ggui/auth.json` (mode `0600`) holds the access bearer, the refresh bearer, and the API endpoint they were minted against.\n\nThe approval window is ~10 minutes. If it expires, just run `ggui login` again.\n\n## Flags\n\n```text\nggui login [--name <label>] [--no-open]\n```\n\n| Flag        | Purpose                                                                                                                                                              |\n| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `--name`    | Device label shown in the console and `ggui whoami`. Defaults to `ggui CLI on <hostname>`. Lets you distinguish `ggui CLI on laptop` from `ggui CLI on workstation`. |\n| `--no-open` | Skip auto-opening the verification URL. Implied when stdout isn't a TTY, when `BROWSER=none`, or when `CI=1` — so the flow stays sane in pipelines.                  |\n\n## Configuration\n\nResolution order for the API endpoint:\n\n1. `GGUI_API_URL` env var — operator override (sandbox / dev testing). Login prints `(env)`.\n2. `~/.ggui/auth.json#endpoint` — captured at last login. Login prints `(auth.json)`.\n3. `https://api.ggui.ai` — production default. Login prints `(default)`.\n\nThe suffix on the first `Endpoint:` line of `ggui login` tells you which tier won — useful when an unexpected `GGUI_API_URL` in your shell silently redirects the device flow at a sandbox.\n\nFor sandbox / non-prod testing:\n\n```bash\nGGUI_API_URL=https://abc123.execute-api.us-east-1.amazonaws.com ggui login\n```\n\nFor an isolated dev shell that shouldn't touch your real session, set `GGUI_CONFIG_DIR`:\n\n```bash\nGGUI_CONFIG_DIR=/tmp/ggui-test ggui login\n```\n\n## Verify\n\n```bash\n$ ggui whoami\nUser ID:        <your-user-id>\nSession ID:     <opaque-session-id>\nClient:         ggui CLI on laptop\nAccess expires: 2026-04-26T18:09:14.000Z\nEndpoint:       https://api.ggui.ai\n```\n\n## Manage keys\n\nOnce signed in, you can mint connector keys from the terminal:\n\n```bash\n$ ggui keys create --name \"my agent runtime\"\n\nAPI key:    ggui_user_AbCdEf...\nID:         a1b2c3d4-...\nPrefix:     AbCdEfGh\nCreated:    2026-04-26T17:32:00.000Z\n\nIMPORTANT: copy the API key now — it will NEVER be shown again.\nUse it as the bearer token for the ggui-protocol-user MCP server.\n```\n\nThe full secret is printed exactly once, GitHub-PAT-style. Lose it and you mint a new one.\n\nPass `--expires-at <iso8601>` to mint a key with a hard expiry.\n\n```bash\n$ ggui keys list\nID                                    PREFIX               NAME              STATUS  LAST USED\na1b2c3d4-...                          ggui_user_AbCdEfGh…  my agent runtime  active  —\n\n$ ggui keys revoke a1b2c3d4-...\nRevoked key a1b2c3d4-....\n```\n\n`revoke` is a soft-revoke — the row stays in the audit table, but every subsequent request from the key returns `401 Unauthorized`. There's no undo.\n\n### No account? Local keys\n\n`keys list / create / revoke` also work with **no account at all**: pass `--keys-file <path>` to flip them to a local JSON store. The file format is the same one `ggui serve --keys-file` reads, so a locally minted bearer authenticates against your own server on the next boot — no `ggui login` needed:\n\n```bash\nggui keys create --keys-file ./keys.json --name laptop\nggui serve --keys-file ./keys.json\n```\n\nThis is the available-today path for self-hosting in the public preview.\n\nSee [`ggui keys register`](/cli/keys-register/) for publishing your **author** Ed25519 key to the marketplace (a different key, with a different purpose).\n\n## Sign out\n\n```bash\n$ ggui logout\nSigned out. ~/.ggui/auth.json removed.\n```\n\n`ggui logout` deletes the local session. Server-side tokens stay valid until their TTL expires (~1h access, ~30d refresh). For sensitive scenarios, follow up with `ggui keys revoke <id>` for any keys minted during that session.\n\n## Troubleshooting\n\n**\"Login window expired before approval\"** — you took longer than ~10 minutes. Run `ggui login` again.\n\n**\"Session expired. Run `ggui login` again.\"** — both access and refresh tokens are dead (you've been away >30 days, or the server invalidated them). Re-authenticate.\n\n**\"failed to start device flow\"** — check `GGUI_API_URL`, your network, and the [troubleshooting page](/troubleshooting/).\n\n**Browser didn't open** — fine. Copy the printed URL into any browser; the verification code in the URL matches the one printed beneath it."},{"path":"/cli/serve.md","title":"ggui serve","description":"Run the self-hosted GGUI runtime — MCP server plus a supervised agent, configured by ggui.json.","body":"---\ntitle: ggui serve\ndescription: Run the self-hosted GGUI runtime — MCP server plus a supervised agent, configured by ggui.json.\n---\n\n`ggui serve` boots the self-hosted GGUI runtime: an MCP server (`@ggui-ai/mcp-server`) plus, by default, a supervised agent process declared in `ggui.json`. It's distinct from [`ggui dev`](/cli/dev/), the inner-loop development hub — `ggui serve` is the production-shaped self-host you'd put behind a tunnel or on a VM.\n\n## Quick start\n\n```bash\nggui serve\n```\n\nBinds `127.0.0.1:6781`, mounts the first-run bundle, and starts the agent declared in `ggui.json#agent.entry` alongside MCP. The CLI auto-opens the landing page once the banner prints (skip with `--no-open`).\n\n**Point an agent at it** — the MCP endpoint is `http://127.0.0.1:6781/mcp`:\n\n```bash\nGGUI_MCP_URL=http://127.0.0.1:6781/mcp\nGGUI_MCP_BEARER=dev   # i.e. `Authorization: Bearer dev` — works with `ggui serve --dev-allow-all`\n```\n\nWith the default strict auth, swap `dev` for a pair-minted bearer (or one minted via `ggui keys create --keys-file <path>`).\n\nFor the bootstrap walkthrough, see the [self-hosted quickstart](/quickstart/self-hosted/).\n\n## The first-run bundle\n\nA default `ggui serve` mounts these same-origin surfaces:\n\n| Path                        | What it serves                                                                                      |\n| --------------------------- | --------------------------------------------------------------------------------------------------- |\n| `/`                         | Landing page — server identity, pair-code card, links into the console                              |\n| `/mcp`                      | MCP HTTP endpoint — agents call `ggui_render`, `ggui_consume`, etc. here.                           |\n| `/ws`                       | Live-channel WebSocket — live session plane for MCP Apps iframes and the console.                   |\n| `/ggui/health`              | Liveness probe — the path the [reference-deploy](/self-hosted/reference-deploys/) healthchecks hit. |\n| `/r/<shortCode>`            | Signed render-viewer URL — resolves a shortCode to its session.                                     |\n| `/settings`                 | LLM provider-key page — paste a key; takes effect without restart.                                  |\n| `/pair`, `/admin/pair/init` | Pairing endpoints for paired viewer clients.                                                        |\n\nHosts that need a different shape (no landing page, no pairing, programmatic control) should compose `createGguiServer()` directly rather than invoke this CLI. `createGguiServer({ mcpServices: [...] })` can also mount additional standalone MCP services at their own paths — see [MCP services](/architecture/mcp-services/).\n\n## Flags\n\n```text\nggui serve [options]\n```\n\n### Bind & lifecycle\n\n| Flag            | Default     | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                  |\n| --------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `--port <n>`    | `6781`      | Bind port. `0` = OS-assigned (the actual port prints in the boot banner).                                                                                                                                                                                                                                                                                                                                                |\n| `--host <addr>` | `127.0.0.1` | Bind host. Loopback only by default.                                                                                                                                                                                                                                                                                                                                                                                     |\n| `--mcp-only`    | off         | Run just the MCP server; skip agent supervision even if `ggui.json` has `agent.entry`. Also implies `--no-open`.                                                                                                                                                                                                                                                                                                         |\n| `--no-open`     | off         | Skip auto-opening the operator's browser. Auto-open is also skipped whenever stdout is not a TTY (CI, supervised, piped output).                                                                                                                                                                                                                                                                                         |\n| `--no-console`  | off         | Serve no operator console: no console routes, and no admin token is minted or printed. A key-less boot only names the environment variables to export, and a key-less render fails with `NO_CREDENTIALS` instead of showing the Connect-Claude card. For a tool that launches `ggui serve` purely as an MCP endpoint. Env twin: `GGUI_NO_CONSOLE=1` (or `true`). `--mcp-only` is different: it still serves the console. |\n\n### Auth posture (mutually exclusive)\n\nBy default, `/mcp` rejects any bearer that wasn't minted by the pairing flow. These flags relax that for specific scenarios:\n\n| Flag              | Posture                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |\n| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `--dev-allow-all` | Accept any bearer — or none at all (the no-bearer probe MCP custom connectors send) — as `builder`. Local-dev / tunnel smoke ONLY. **Never expose to the open internet** — the banner prints an unmissable warning when this is set.                                                                                                                                                                                                                                             |\n| `--public-demo`   | Same any-bearer auth as `--dev-allow-all`, plus a per-IP `FixedWindowRateLimiter` on `ggui_render` (default: 30 `ggui_render` calls per 10 min per IP) and a \"PUBLIC DEMO — operator pays\" banner. Use case: a single shared LLM key for an audience demo (Show HN, blog, classroom). Mutually exclusive with `--dev-allow-all`.                                                                                                                                                 |\n| `--multi-user`    | Strict-auth multi-user posture. The console `/settings` LLM-keys gate switches from admin-token to auth-adapter so each authenticated end-user manages their OWN provider keys (scope = `userId` for `kind:'user'`, `appId` for `kind:'app'`). `kind:'builder'` identities are rejected. Mutually exclusive with `--dev-allow-all` and `--public-demo`. Note: the admin-token `/keys` pairing plane is separate from the `/settings` LLM-keys plane that `--multi-user` rebinds. |\n\n### Custom connector hosts\n\n| Flag                    | Purpose                                                                                                                                                                                                                                                                                                                                                                         |\n| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `--oauth`               | Mount OAuth 2.1 + PKCE + Dynamic Client Registration routes (`.well-known/oauth-*` + `/oauth/{authorize,token,register}`). Required for hosts whose Add Connector form has no field for a pre-shared bearer (claude.ai, ChatGPT). Pure-bearer clients (Claude Desktop with bearer in config) work without it.                                                                   |\n| `--public-base-url <u>` | Override the public base URL used to compose the iframe-runtime + live-channel URLs written into each render's `ai.ggui/render` slice. Set to a tunnel URL (`https://<random>.trycloudflare.com`) when testing against a remote MCP host so those URLs resolve from the host's perspective. Without this, they derive from `--host:--port` and only work from the same machine. |\n\n### Browser clients\n\n| Flag                        | Purpose                                                                                                                                                                                                                                                                                                                                          |\n| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `--browser-origin <origin>` | Repeatable. Allow pages served from this origin past MCP-wire Origin validation, and emit it in the CORS headers. Pages on localhost need no entry — loopback is always allowed. Env twin: `GGUI_BROWSER_ORIGINS` (comma-separated). This is NOT authentication — `/mcp` still requires a bearer. Non-browser clients are unaffected either way. |\n\nA browser-based MCP client on any other origin is rejected before auth runs, so the failure looks like an opaque Origin rejection rather than a 401. `GET /ggui/health` reports the effective allowlist under `cors.origins`, and the boot banner prints how many origins are allowed.\n\n### Operator config\n\n| Flag                     | Purpose                                                                                                                                                                                                                                                                                                         |\n| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `--admin-token <token>`  | Pin the admin bearer that gates the console `/keys` plane. Without this, the server mints a fresh `ggui_admin_*` per boot and prints it on the banner. Pin a stable value when you want the bearer to survive restarts.                                                                                         |\n| `--keys-file <path>`     | JSON file backing the pairing service. When set, paired bearers survive restart. Stored in plaintext at `0600` perms — assume operator-controlled disk (e.g. `~/.ggui/keys.json`).                                                                                                                              |\n| `--ephemeral`            | Opt out of the default cross-restart persistence bundle (`.ggui/persistent/`). With this flag, HMAC secrets, renders, vectors, short-codes, and paired bearers all reset on every restart. Use for tests, CI loops, or incident-response nuclear-revoke.                                                        |\n| `--seed-pool <dir>`      | Repeatable. Load a read-only shared blueprint pool (a [`ggui export-pool`](/cli/) artifact) for exact-contract reuse, consulted after the server's own blueprints.                                                                                                                                              |\n| `--mcp-instructions <p>` | Server-level MCP instructions preset (the string injected into the LLM's system prompt above the tool catalog). Presets: `default`, `aggressive`, `always`, `minimal`, `off`. Unset sends none. Also accepts `GGUI_MCP_INSTRUCTIONS` env (CLI flag wins).                                                       |\n| `--local-cli-login`      | Opt-in: generate with the Claude login already on this machine (the one `claude` uses) when no Anthropic API key is set. Anthropic routes only; a real key always wins; refused with `--multi-user` and `--public-demo`. See [Without an API key](#without-an-api-key-the-claude-login-on-this-machine-opt-in). |\n\n## Agent runtime supervision\n\nBy default, `ggui serve` boots your agent alongside MCP. The entry file comes from `ggui.json`:\n\n```json\n{\n  \"agent\": { \"entry\": \"./agent.ts\" }\n}\n```\n\nSupported extensions:\n\n- `.js` / `.mjs` / `.cjs` — runs as `node <entry>`\n- `.ts` / `.tsx` / `.mts` — runs as `node --import=tsx <entry>` (`tsx` must be resolvable in your project)\n\nFailure modes:\n\n- **No `ggui.json`** or **no `agent.entry`** — falls back to MCP-only with a warning. Useful when you want to point an external agent runtime at the server.\n- **Malformed `ggui.json`** or **unsupported entry extension** — hard error, exits 1 before binding.\n- **Agent crashes after startup** — logged, MCP keeps running. No auto-restart — compose that with your own supervisor (systemd, pm2, Docker restart policy).\n\n## Generation (bring your own key)\n\nComponent-code generation on a self-hosted server uses **your** LLM provider key (BYOK). At boot, `ggui serve` resolves a key in this order:\n\n1. **Provider env vars** — `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY` (falling back to `GEMINI_API_KEY`), `OPENROUTER_API_KEY`. The env layer always wins.\n2. **`~/.ggui/credentials.json`** — keys pasted at `/settings` land here (plaintext, `0600`).\n\nThe model comes from `ggui.json#generation.model`, in either `provider:model` (canonical) or `provider/model` (LiteLLM) form:\n\n```json\n{\n  \"generation\": { \"model\": \"anthropic:claude-haiku-5-5\" }\n}\n```\n\n`GGUI_GENERATION_MODEL` env overrides the manifest model — the precedence is **`GGUI_GENERATION_MODEL` env > `ggui.json#generation.model`**. Without a `ggui.json`, the provider's default route applies. With one, a key without a model is a hard error (see the rules below). The env value accepts both `provider:model` (canonical) and `provider/model` (LiteLLM) forms; a malformed value is a hard boot error, not a silent fallback. It's the ops escape hatch for pointing one `ggui serve` instance at a different model without editing the manifest. The per-provider defaults are what that error suggests as the model to pick, and what a server without a `ggui.json` uses (`ZERO_CONFIG_ROUTE_BY_PROVIDER`, exported by `@ggui-ai/protocol`, whose Anthropic route is the open-source default: anthropic `claude-haiku-5-5`, openai `gpt-5.6-luna`, google `gemini-3.5-flash-lite`, openrouter `anthropic/claude-haiku-5.5`). Any id the protocol registry accepts works in `generation.model` — for Anthropic that is the lineup (`claude-haiku-5-5`, `claude-sonnet-5-5`, `claude-opus-5-5`, and `claude-fable-5-1`, the most capable) and the legacy models that still route (`claude-haiku-4-5`, `claude-sonnet-5`, `claude-opus-5`, `claude-fable-5`, the 4.6–4.7 generation); an id outside the registry is rejected at parse time.\n\nRules:\n\n- **A key without a model is a hard error** — if a boot key resolves but neither `generation.model` nor `GGUI_GENERATION_MODEL` sets a model, `ggui serve` exits with an actionable message that shows both accepted model-string forms and suggests the provider's default.\n- **Bedrock routes are rejected** on the OSS path (IAM-only; supported via the hosted runtime only).\n- **No key at all is not fatal** — the banner prints `⚠ no LLM key configured`, and renders fall back to a Connect-a-key card pointing at `/settings`. Pasting a key there takes effect without a restart.\n\n### Without an API key: the `claude` login on this machine (opt-in)\n\n`ggui serve --local-cli-login` generates with the Claude login already on this machine, the one the `claude` command uses, instead of an API key. It runs Claude Code as Anthropic ships it in the Agent SDK, unmodified. GGUI never asks for your Claude credentials and never stores them: Claude Code signs in with its own login store, or with a `CLAUDE_CODE_OAUTH_TOKEN` you set in the environment, which GGUI passes to it unchanged. Nothing is sent through a GGUI account. Sign in by running `claude` yourself first.\n\n- **Your Claude plan's terms and usage limits apply.**\n- **It is slower than an API key.** Measured on the same model: ~2.2x slower at the median; ~2.3-5 s of each serial call is the local binary startup (load-dependent); the rest of the gap is unmeasured.\n- **A real key always wins.** With `ANTHROPIC_API_KEY` set, or an Anthropic key in `~/.ggui/credentials.json`, the flag has no effect.\n- **Anthropic routes only.** The model rules above still apply, so a `ggui.json` still needs an `anthropic:` `generation.model`. On any other provider the server says the flag has no effect.\n- **For your own use.** One person's plan never serves other users, so the flag is refused with `--multi-user` and `--public-demo`.\n- **It can stop working** if Anthropic changes how Claude Code signs in, or how it runs under the Agent SDK.\n\nThe boot banner names the source: `generation: anthropic / <model> (the claude command's login on this machine — your Claude plan's terms and usage limits apply)`.\n\n## Persistent storage\n\nBy default, `ggui serve` writes a cross-restart bundle under `.ggui/persistent/` (project-local when a `ggui.json` was resolved, else `~/.ggui/persistent/`) so HMAC secrets, renders, vectors, short-codes, and paired bearers survive a restart. claude.ai chat-history revisits keep working without re-pairing.\n\nBundle layout:\n\n```text\n.ggui/persistent/\n├── ws-token-secret.hex       (HMAC, 0600)\n├── short-codes.sqlite        (signed render-URL resolution — backs the /r/<shortCode> viewer)\n├── sessions.sqlite            (GguiSessionStore — renders + event history)\n├── vectors.sqlite            (blueprint cache — vectors + their identity index)\n└── keys.json                 (paired bearers)\n```\n\nOverride the directory with `GGUI_PERSISTENT_DIR`. Pass [`--ephemeral`](#operator-config) to skip the bundle entirely.\n\nExplicit `ggui.json#storage` declarations always win. Declare them to override paths or swap a single surface back to memory while keeping the rest persistent:\n\n```json\n{\n  \"storage\": {\n    \"renders\": { \"driver\": \"sqlite\", \"path\": \"./ggui-sessions.sqlite\" },\n    \"vectors\": { \"driver\": \"sqlite\", \"path\": \"./ggui-vectors.sqlite\" },\n    \"threads\": { \"driver\": \"sqlite\", \"path\": \"./ggui-threads.sqlite\" }\n  }\n}\n```\n\n| Store     | Default                                                                                                  | sqlite driver requires    |\n| --------- | -------------------------------------------------------------------------------------------------------- | ------------------------- |\n| `renders` | sqlite under `.ggui/persistent/sessions.sqlite` (opt out with `--ephemeral` or `{ \"driver\": \"memory\" }`) | `better-sqlite3` peer dep |\n| `vectors` | sqlite under `.ggui/persistent/vectors.sqlite` (opt out with `--ephemeral` or `{ \"driver\": \"memory\" }`)  | `better-sqlite3` peer dep |\n| `threads` | **routes unmounted unless declared** (opt-in)                                                            | `better-sqlite3` peer dep |\n\nPaths in `ggui.json#storage` resolve relative to the `ggui.json` directory, regardless of where `ggui serve` was invoked from.\n\nFor threads specifically, `driver: \"memory\"` mounts the routes but data resets on restart (`/ggui/health` reports `threads.durability: \"ephemeral\"`); `driver: \"sqlite\"` is durable.\n\n## Recommended setups\n\n**Local-only smoke (no tunnel, no remote MCP host):**\n\n```bash\nggui serve\n```\n\n**claude.ai custom connector (over a public tunnel):**\n\n```bash\n# Terminal 1: tunnel\ncloudflared tunnel --url http://localhost:6781\n\n# Terminal 2: serve\nggui serve --oauth \\\n           --public-base-url https://<tunnel>.trycloudflare.com\n```\n\nNote the `PAIR_CODE` from the boot banner. Visit the public URL, complete pairing, save the bearer. Then in claude.ai → Settings → Connectors → Add custom connector, point it at `https://<tunnel>.trycloudflare.com/mcp`, leave Client ID / Secret empty (the server uses Dynamic Client Registration), click Connect, and paste the bearer when prompted.\n\n**Quick local-dev without auth (NEVER over a public tunnel):**\n\n```bash\nggui serve --dev-allow-all\n```\n\nFor the full pairing walkthrough, see [Self-hosted pairing](/self-hosted/pairing/).\n\n## Production hardening\n\nThe default auth shape is dev-mode pairing. For production, swap in a real `AuthAdapter` by composing `createGguiServer()` directly in your agent entrypoint instead of running the CLI:\n\n```typescript\nimport { createGguiServer } from \"@ggui-ai/mcp-server\";\n\nconst server = createGguiServer({\n  auth: {/* your AuthAdapter — OIDC, Cognito, custom */},\n});\n```\n\nThe adapter gates both `/mcp` and the live-channel `/ws` upgrade. See [Reference deploys](/self-hosted/reference-deploys/) for Docker / Fly / Render manifests.\n\n## Current limits\n\n- Strict-auth only — `/mcp` rejects any bearer not pair-minted (or relaxed via `--dev-allow-all` / `--public-demo`).\n- Single-user by default — every request scopes to one `builder` app ID. Use `--multi-user` to scope per authenticated user.\n- No auto-restart on agent crash — compose with your own supervisor.\n\n## See also\n\n- [`ggui` CLI overview](/cli/) — the full command surface.\n- [`ggui login`](/cli/login/) — sign into `api.ggui.ai` for `ggui_user_*` connector keys (separate from `ggui serve`'s pairing flow).\n- [Self-hosted pairing](/self-hosted/pairing/) — pair a viewer client to a `ggui serve` instance.\n- [Reference deploys](/self-hosted/reference-deploys/) — Docker, Fly, Render manifests for `ggui serve`."},{"path":"/clients/claude-code.md","title":"Connect Claude Code","description":"Add hosted GGUI to Claude Code, sign in, and know what to expect — the tools work, but Claude Code shows results as text, not as cards.","body":"---\ntitle: Connect Claude Code\ndescription: Add hosted GGUI to Claude Code, sign in, and know what to expect — the tools work, but Claude Code shows results as text, not as cards.\n---\n\nClaude Code can call GGUI's tools, but it isn't an MCP Apps host: it doesn't mount the interface GGUI builds, so **no card appears in Claude Code**. Claude reads the tool result and tells you about it in text. To see and use the generated interface, connect the same account in [Claude Desktop](/quickstart/claude-desktop/) or claude.ai.\n\n## Add the server\n\n```bash\nclaude mcp add --transport http ggui https://mcp.ggui.ai\n```\n\nThat adds GGUI for the current project only. Add `--scope user` to make it available in every project.\n\n## Sign in\n\nClaude Code doesn't open the sign-in on its own. Run `/mcp` inside Claude Code, pick **ggui**, and follow the steps in your browser: sign in at the GGUI console and click **Approve**. The console creates the access key and hands it to Claude Code; there's nothing to copy.\n\n## What you get\n\n- **The tools work.** Claude can negotiate, render and read results, and each render is made under your account and charged like any other.\n- **No inline card.** Claude Code ignores the interface GGUI attaches to each result, so you see Claude's description of it instead.\n- **Long tool descriptions are trimmed.** Claude Code cuts each MCP tool description, and the server's instructions, to 2,048 characters. GGUI's tools still work at that length; to lift the limit, set the `CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH` environment variable higher before starting Claude Code.\n\n## Other hosts\n\n[Connect other MCP hosts](/clients/connect-other-hosts/) covers Cursor, VS Code, Goose and the rest, and the [compatibility matrix](/api/mcp-apps/#compatibility-matrix) shows which ones render cards."},{"path":"/clients/claude-desktop.md","title":"Connect Claude Desktop","description":"Connect Claude Desktop to a GGUI server over MCP with OAuth and render generative UI inline.","body":"---\ntitle: Connect Claude Desktop\ndescription: Connect Claude Desktop to a GGUI server over MCP with OAuth and render generative UI inline.\n---\n\n`mcp.ggui.ai` speaks the [MCP Apps](/api/mcp-apps/) protocol, so any MCP-Apps-aware host renders ggui-generated UIs inline. Claude Desktop is the most polished client today; this page walks install and first use end to end.\n\nThe same procedure works for **claude.ai**, **Goose**, and **VS Code Copilot** — anywhere you can add a remote MCP server and the host implements OAuth 2.1 + Dynamic Client Registration. See [Other clients](#other-clients).\n\n## Available now: connect your own server\n\nThe hosted `mcp.ggui.ai` endpoint is live — sign in at [the GGUI console](https://console.ggui.ai) to mint a connector key. The identical \"Add custom connector\" flow also works against a self-hosted server today:\n\n1. Run your server with OAuth enabled, on a URL Claude's browser can reach:\n\n   ```bash\n   ggui serve --oauth --public-base-url https://<your-tunnel>\n   ```\n\n2. In Claude Desktop, **Settings → Connectors → Add custom connector**, paste `https://<your-tunnel>/mcp`.\n3. On first use the consent page asks you to paste a locally-minted `ggui_user_*` key — that key becomes the OAuth access token.\n\nSee [CLI: serve](/cli/serve/) for the flags and [Pairing](/self-hosted/pairing/) for minting keys.\n\n## Step 1: Add the server\n\nIn Claude Desktop, open **Settings → Connectors → Add custom connector** and paste:\n\n```\nhttps://mcp.ggui.ai\n```\n\nLeave authentication blank — the server drives Claude through the OAuth ceremony on first use. Save.\n\n## Step 2: Approve the connection\n\nThe first time Claude calls a GGUI tool (or you click **Connect** on the connector card), Claude pops a browser window to `console.ggui.ai/oauth/consent`.\n\n1. Sign in to GGUI (Google, GitHub, or email — same account that holds your connector keys).\n2. Click **Approve** on the consent card. The console mints a fresh `ggui_user_*` API key labelled `Claude Desktop` and hands it to the client through the OAuth flow.\n3. The browser closes. Claude shows the connector as connected.\n\nUnder the hood, no manual `client_id` to copy:\n\n- Auth-server discovery via [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) (`/.well-known/oauth-protected-resource`) and [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414).\n- Client registration via [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) Dynamic Client Registration.\n- [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) + [PKCE](https://datatracker.ietf.org/doc/html/rfc7636); the `ggui_user_*` key becomes the access token.\n\nFull protocol details: [OAuth on mcp.ggui.ai](/self-hosted/oauth/).\n\n## Step 3: Generate a UI\n\nIn a new chat, ask Claude to do something concrete enough to need a form:\n\n> Help me triage 12 incoming product-support tickets. Show me a sortable table with severity, age, and a \"draft reply\" action per row.\n\nClaude calls the `ggui_render` tool with that intent. The MCP Apps capability advertised by `mcp.ggui.ai` tells Claude Desktop to render the result inline — the table appears directly in the chat, not as an external link. Submit a row and the data flows back to Claude as if you'd typed it.\n\nThe first such call also seeds a blueprint cache: subsequent matching requests skip the generation step and reuse the cached blueprint.\n\n## Tool-call budget and credits\n\nA new account starts with a one-time welcome credit while it is available (no card) and renders on it at the published per-render rates; when it is spent, top up at [`console.ggui.ai/credits`](https://console.ggui.ai/credits) and continue — there is no plan to pick and nothing is metered monthly. Rates and the welcome credit are on [ggui.ai/pricing](https://ggui.ai/pricing); the mechanics are in [Credits, billing & BYOK](/hosted/billing/).\n\nPrefer your own model account? Paste a provider key into [`console.ggui.ai/keys/providers`](https://console.ggui.ai/keys/providers) (BYOK — Anthropic, OpenAI, Google, OpenRouter). A stored key alone doesn't redirect billing — see [Credits, billing & BYOK](/hosted/billing/) for what has to be true first.\n\n## Revoke the connection\n\nTwo places to look, one way to revoke:\n\n- **Claude Desktop** — Settings → Connectors → GGUI → **Disconnect**. Drops the cached token locally; the underlying API key on the GGUI side is unaffected.\n- **console.ggui.ai** — the [keys table](/hosted/connector-keys/#listing-and-revoking) is read-only; revoke from the CLI instead: find the key labelled `Claude Desktop` (or whatever the client called itself) with `ggui keys list`, then run `ggui keys revoke <id>`. The next request from any client holding that token returns `401`.\n\nThe keys table at [`console.ggui.ai/keys/connector`](https://console.ggui.ai/keys/connector) is the authoritative record of every connector key — every MCP-Apps client lands as one row, labelled with its DCR `client_name` — but revocation itself is a CLI-only operation. (A dedicated **Connected apps** view at `/connections` is a placeholder today; the keys table is what to use to identify which key to revoke.)\n\n## Other clients\n\nThe same `mcp.ggui.ai` URL works in any host that ships MCP Apps + OAuth-DCR:\n\n- **claude.ai (web)** — Settings → Connectors → Add custom connector. Same flow, browser-only.\n- **Goose** — `goose configure`, then add a remote MCP server pointing at `https://mcp.ggui.ai`.\n- **VS Code Copilot** — drop into `.vscode/mcp.json`: `{ \"servers\": { \"ggui\": { \"type\": \"http\", \"url\": \"https://mcp.ggui.ai\" } } }`. Copilot triggers OAuth on first tool call.\n- **Anything that speaks RFC 7591 + RFC 9728** — drop the URL in; the server's discovery metadata bootstraps the rest.\n\nHosts that don't speak MCP Apps yet (most CLI agents at time of writing) can still hit the endpoint with a static `Authorization: Bearer ggui_user_*` header. They lose inline rendering — `ggui_render` still works and returns the render as a `ui://ggui/render/<id>` resource, but without MCP-Apps support there's nothing to mount it (the agent receives no URL to open).\n\n## Troubleshooting\n\n**\"Failed to connect\" after approving** — stale token cache. Disconnect from Claude Desktop's connector panel and reconnect; OAuth re-runs and mints a fresh key.\n\n**OAuth browser window never opens, or closes with an error** — most often a popup blocker on `console.ggui.ai`, or you were already signed into a different GGUI account in that browser profile. Allow popups for `console.ggui.ai`, sign out of the wrong account, and click **Connect** on the connector card again to restart the ceremony. To inspect what was minted, open [`console.ggui.ai/keys/connector`](https://console.ggui.ai/keys/connector) — a successful ceremony shows up as a new row labelled with the client name.\n\n**\"This MCP server has not been activated\"** — your account has zero credits and no BYOK key on file. Top up at [`console.ggui.ai/credits`](https://console.ggui.ai/credits) or paste a provider key into [`console.ggui.ai/keys/providers`](https://console.ggui.ai/keys/providers).\n\n**Nothing renders, just text** — the host doesn't advertise MCP Apps capability. There is no link to open by hand; a GGUI render is a `ui://ggui/render/<id>` resource, and only a host that mounts it can show it. Upgrade to a current Claude Desktop build (MCP Apps support shipped late 2025). The session still exists server-side, so any MCP-Apps host can mount it.\n\n**Anything else** — see [Troubleshooting](/troubleshooting/)."},{"path":"/clients/connect-other-hosts.md","title":"Connect other MCP hosts","description":"Wire Cursor, VS Code Copilot, claude.ai, Goose, Cline, Continue, or Windsurf into a GGUI server over MCP HTTP.","body":"---\ntitle: Connect other MCP hosts\ndescription: Wire Cursor, VS Code Copilot, claude.ai, Goose, Cline, Continue, or Windsurf into a GGUI server over MCP HTTP.\n---\n\nA GGUI server is a plain remote MCP server. Any host that can be pointed at an MCP HTTP URL can use it — Claude Desktop is just the most polished today. This page is the \"everything else\" companion to [Connect Claude Desktop](/clients/claude-desktop/), which stays the deep-dive for that one client.\n\n:::note[Hosted endpoint, or self-host]\nThe examples below show the hosted `mcp.ggui.ai` URL — sign in at [the GGUI console](https://console.ggui.ai) to mint a connector key. Prefer to self-host? Run your own server with `ggui serve --oauth --public-base-url https://<your-tunnel>` and substitute `https://<your-tunnel>/mcp` wherever you see `mcp.ggui.ai` below — every host flow is identical, only the URL changes. See [Connect Claude Desktop → connect your own server](/clients/claude-desktop/#available-now-connect-your-own-server) for the self-hosted walkthrough.\n:::\n\nEach section below covers WHAT to paste, WHERE to paste it, the expected OAuth flow, and any inline-rendering caveat. If your host isn't listed but speaks MCP over HTTP, jump to [Other / static-key fallback](#other--static-key-fallback).\n\n## claude.ai (web)\n\nAnthropic's web client gained MCP Apps support late 2025; the flow mirrors Claude Desktop exactly.\n\n1. **Settings → Connectors → Add custom connector**.\n2. Paste `https://mcp.ggui.ai`. Leave auth blank.\n3. On first tool call, claude.ai opens an OAuth tab to `console.ggui.ai/oauth/consent`. Approve.\n\nInline rendering works — generated UIs appear directly in the chat.\n\n:::tip\nThe OAuth ceremony is identical to Claude Desktop. See [OAuth on mcp.ggui.ai](/self-hosted/oauth/) for the RFC-by-RFC breakdown.\n:::\n\n## Cursor\n\nCursor reads MCP server config from `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per-project, takes precedence).\n\n```json\n{\n  \"mcpServers\": {\n    \"ggui\": {\n      \"url\": \"https://mcp.ggui.ai\"\n    }\n  }\n}\n```\n\nRestart Cursor. The first time the agent calls a GGUI tool, Cursor opens the browser OAuth flow. Approve at `console.ggui.ai/oauth/consent` and the agent picks up the minted token automatically.\n\n:::caution\nCursor's MCP Apps inline-rendering support is partial and moves quickly — verify against your installed build. If your build doesn't render inline, the tool call still succeeds and returns a `ui://ggui/render/<sessionId>` resource, but there is no URL to open manually — the session round-trip works, the UI just can't be mounted.\n:::\n\n## VS Code Copilot (and Cline, Continue, Codeium)\n\nVS Code's native MCP support shipped in 2025. Drop into either `.vscode/mcp.json` (per-project) or your User Settings JSON:\n\n```json\n{\n  \"servers\": {\n    \"ggui\": { \"type\": \"http\", \"url\": \"https://mcp.ggui.ai\" }\n  }\n}\n```\n\nReload the window. Copilot triggers OAuth on first tool call.\n\n**Cline**, **Continue**, and **Codeium** are VS Code extensions that ship their own MCP config surfaces. The JSON shape is the same `mcpServers`-style object with minor key naming differences per extension — drop the same URL into whichever `mcp.json` the extension reads:\n\n- **Cline** — extension settings → MCP Servers → Edit config.\n- **Continue** — `~/.continue/config.json` under `mcpServers`.\n- **Codeium** — extension settings → MCP integration.\n\nConsult each extension's docs for the exact path; the URL itself is unchanged.\n\n:::note\nInline-rendering support across these extensions varies. The transport works everywhere; only the MCP Apps capability negotiation determines whether you get an embedded UI or a link to open.\n:::\n\n## Goose\n\nBlock's open-source agent has full MCP Apps support, so inline rendering works in the Goose Desktop UI out of the box.\n\n```\ngoose configure\n```\n\nChoose **Add extension → Remote MCP Server**, give it a name (`ggui`), and paste:\n\n```\nhttps://mcp.ggui.ai\n```\n\nGoose drives the OAuth ceremony in the terminal flow — it prints a URL, you sign in at `console.ggui.ai`, and Goose stores the resulting token.\n\n## Windsurf\n\nCodeium's IDE supports remote MCP servers via Settings → MCP Servers → Add. Paste `https://mcp.ggui.ai` and save; OAuth runs on first tool call.\n\nThe config-file location varies by OS and Windsurf build. Settings UI is the safest path.\n\n:::caution\nVerify against your current Windsurf build — MCP Apps capability advertisement and inline-rendering behavior have changed across releases.\n:::\n\n## Other / static-key fallback\n\nAny host that can be pointed at an MCP HTTP URL with a bearer token can hit `mcp.ggui.ai`. For hosts that don't yet implement OAuth 2.1 + DCR — most CLI agents and home-grown clients — use a static API key instead:\n\n1. Sign in at [`console.ggui.ai/keys/connector`](https://console.ggui.ai/keys/connector).\n2. Name the key after the client, then click **Generate connector key**.\n3. Configure the host to send the key as a bearer header:\n\n   ```\n   Authorization: Bearer ggui_user_xxxxxxxxxxxx\n   ```\n\nThat's the full setup. See [`ggui keys create`](/cli/login/#manage-keys) for minting a connector key from the terminal instead of the console.\n\n:::note\nInline rendering depends on MCP Apps capability negotiation in the host. Hosts without that capability still get a working `ggui_render` — it returns the render as an MCP-Apps resource (`resourceUri`); the host just can't mount it inline. The session round-trip itself is unaffected.\n:::\n\n## Things every host needs\n\nEverything on this page also works against a self-hosted `ggui serve` — substitute your server URL; add `--oauth` for hosts that drive the browser ceremony, or use a pair-minted bearer for the static-key path.\n\nFor a working connection:\n\n- **MCP over HTTP** — `mcp.ggui.ai` is HTTP-only. Stdio-only hosts can't connect directly.\n- **Bearer authentication** — either via the OAuth flow below, or a static `ggui_user_*` key in an `Authorization` header.\n\nFor the seamless one-click flow:\n\n- **OAuth 2.1 + Dynamic Client Registration** ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591), [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414), [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728), [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)). The host discovers, registers, and exchanges tokens with zero manual `client_id` paste. Details: [OAuth on mcp.ggui.ai](/self-hosted/oauth/).\n\nFor inline rendering:\n\n- **MCP Apps capability** — the host advertises that it can render `ui://` resources in-line. Without it, the render is still produced (as a `ui://ggui/render/<id>` resource) but can't be mounted; with it, generated UIs appear inside the chat or agent surface. Details: [MCP Apps capability](/api/mcp-apps/).\n\nAnything else: see [Troubleshooting](/troubleshooting/)."},{"path":"/concepts/blueprints-and-the-cache.md","title":"Blueprints and the cache","description":"What a blueprint is, how the two-tier matcher decides to reuse one, what promotion and durability actually do, and which parts of the hit ratio you can see today.","body":"---\ntitle: Blueprints and the cache\ndescription: What a blueprint is, how the two-tier matcher decides to reuse one, what promotion and durability actually do, and which parts of the hit ratio you can see today.\n---\n\nA blueprint is a render promoted to a reusable recipe. The first time your agent asks for a feedback card, GGUI generates one. The stored result — component code, the contract it satisfies, the intent that produced it — becomes a candidate the matcher can serve the next time an ask looks the same. Where **gadgets** are ingredients and **tools** are actions, blueprints are the already-solved screens.\n\nBlueprints live in a per-app pool. Matching them is the job of GGUI's reuse layer, the **semantic cache for generative UI**. It runs at `ggui_handshake`, before any generation is attempted; `ggui_render` then executes whichever path the handshake chose.\n\n## The reuse key\n\nTwo values decide identity. `contractHash` is the canonical hash of the data contract — fields, types, specs — so the same hash means the same data flow. `variantKey` is the canonical hash of the design-time variance (persona, aesthetic, seed prompt, context). The pair is the reuse key: the same pair reuses one component, and a different variant of the same contract gets its own row.\n\nPassing `override.contract` on render re-aims: the render resolves at the new contract's key and reuses the component stored there if one exists — an override identical to a cached contract serves that card. An override is a request for a different contract, not for new code; `forceCreate` on the handshake is the request for new code.\n\n## The match pipeline\n\nMatching has two tiers, and they have very different costs and postures.\n\n**Tier 1 — exact contract-key equality.** When your agent supplied a contract, the matcher looks up the canonical key directly. It is deterministic, it calls no model at all, and a hit is a guaranteed-correct reuse: the same canonical contract means the same data flow.\n\n**Tier 2 — retrieval plus a rerank judge.** When tier 1 misses, candidates are retrieved by cosine similarity over embeddings and a fast model is asked to pick among them. The judge is shown your intent prose, a summary of your request's contract and, for each candidate in your app's own pool, its id, its similarity score, the intent it was stored with (truncated to 280 characters), and a one-line structural summary of its contract — `slots=name:type; actions=name(payloadFields); streams=name; props=name:type` — and nothing else. It never sees props values or event payloads. A judge that declines every candidate, or picks one below the confidence threshold, falls through to cold generation rather than serving a wrong blueprint.\n\nOn hosted GGUI, TypeSafe AI's Jev model makes this choice first. It is shown the same fields except the similarity scores, and its answer that none fits is final. When it does not answer in time, returns an error, or is not confident in its pick, the judge above decides instead. See [Trust & security](/hosted/trust/#what-generation-actually-sees).\n\nThe reranker exists because raw cosine similarity cannot tell a `sendChip` action that carries `{chipText}` apart from a payload-less `sendChip` — and serving the wrong one silently drops the user's input.\n\nThe outcome surfaces on the handshake as `suggestion.origin`: `cache` (a blueprint matched), `agent` (generate against your draft), or `synth` (generate against an amended draft). See [How GGUI works](/how-it-works/) for the full five-moment walk-through.\n\n## What a hit costs\n\nNothing. Only generation is priced, from the tokens of the LLM call it made — so a render matched against a cached blueprint has nothing to bill. Details on [Credits, billing & BYOK](/hosted/billing/).\n\nBe honest with yourself about what the cache buys: today it saves the generation charge, not wall-clock time. Treat it as a cost lever and a consistency lever — the same ask returns the same component — rather than a latency guarantee.\n\n## Promotion\n\nPromotion is **automatic and best-effort**, not something you call.\n\nWhen a render misses the cache, generates successfully, and the deployment has a blueprint cache bound, the produced component is registered into the pool as part of finishing that render. If the registry write fails — a full disk, a misconfigured vector index — the render still succeeds and returns normally. Only the future cache hit is lost, and a `cache_write_failed` event is logged. The registry write is a performance optimization, never a correctness dependency, which is why it can never fail a render that already produced working code.\n\nTwo other write paths put rows in the same pool, and the matcher reads all three identically:\n\n| Path                          | Who triggers it                                                |\n| ----------------------------- | -------------------------------------------------------------- |\n| Cold generation               | Automatic, on a successful `ggui_render` that missed the cache |\n| `ggui_ops_register_blueprint` | You, deliberately — hand-authored or imported rows             |\n| `ggui blueprint install`      | You, installing a marketplace or local artifact                |\n\nThe pool is capped per kind and evicts the lowest-hit entry first when full. Installed rows are exempt from that eviction — the install bridge owns their lifecycle.\n\n## Durability\n\nBlueprints carry **no TTL**: a blueprint is not on a clock.\n\nA registration that mints a fresh row writes through to a durable pair: the compiled body goes to object storage keyed by its content hash, and the metadata row points at that hash. The body is written first, deliberately. An orphan body is invisible and reclaimable; a row naming a hash with no body behind it is a lie that fails later, at the exact point it looked like it would succeed. If the body write fails, the row is still recorded — without the code hash, a state the protocol already models as \"metadata recorded, no body stored\".\n\nReclamation is operator-invoked, never scheduled: a takedown, an app retirement, or collection of bodies that have gone unreferenced long enough to be safely orphaned. A takedown records the content hash as a tombstone, and **both** halves of the durable write consult it — body upload and metadata row alike, with a consistent read. That is what makes removal stick: bodies are content-addressed, so without the body-side gate a later cold generation producing byte-identical code would simply re-upload to the same key.\n\nRetention across renders, blueprints, and account deletion is laid out in [Trust & security](/hosted/trust/#deletion--retention).\n\n## Sharing a pool\n\nA pool is per-app by default, but it does not have to stay there.\n\n- **Between your own deployments.** `ggui export-pool` writes this deployment's reusable blueprints as a directory artifact; another deployment loads it with `ggui serve --seed-pool <dir>`. Seed pools are read-only, additive, and exact-key only — they are never written at runtime.\n- **Publicly.** A blueprint repo carries a `ggui.blueprint.json` manifest and publishes as a signed, versioned, content-addressable artifact. Browse what others have published at [hub.ggui.ai](https://hub.ggui.ai), or install with `ggui blueprint install`. The mechanics, signing postures, and what a signature does and does not prove are on the [marketplace](/hosted/marketplace/) page.\n\n## Measuring your hit ratio\n\nThree surfaces exist today, and none of them is a dashboard.\n\n**Per render, on the wire.** `ggui_render` returns a `cache` marker alongside the render:\n\n```ts\n// ggui_render output — the cache marker:\n{\n  hit: true,                    // a stored component was served, no code generated\n  llmCallsAvoided: 1,           // generation calls skipped (0 on a fresh generation)\n  cachedBlueprintId: \"bp_…\",    // the matched component id\n  similarity: 0.91,             // cosine similarity — semantic matches only\n  kind: \"full-template\",        // full-template = stored component served; cold = freshly generated\n  reason: \"match-exact: …\"      // why this render reused or generated\n}\n```\n\nLog `hit` and `llmCallsAvoided` per call and you have your own hit ratio, computed from the same values the server used. `reason` is available by default — you do not need a verbose trace to find out why a match was declined.\n\n**Per handshake.** `suggestion.origin` tells you the routing decision one call earlier, before you commit.\n\n**Per app, on the control plane.** `ggui_ops_list_blueprints` enumerates blueprint metadata under an app you operate, filterable by contract hash, generator, persona, or intent keywords. It returns metadata only — code bodies stay in the store and are fetched by a render on a hit.\n\nThe GGUI console's per-app **Blueprints** tab manages your named blueprint library — rename, edit metadata, view source, delete — and shows a render count per named blueprint. It is not a view of the auto-promoted generation pool, and it does not aggregate a hit rate.\n\n:::caution[Vision]\nAn aggregate hit-rate and blueprint-reuse view in the console is not built. Until it is, the numbers above are the ones you can trust, and instrumenting them agent-side is the only way to see the ratio over time.\n:::"},{"path":"/concepts/gadgets.md","title":"Gadgets — deterministic islands","description":"Why third-party capability enters generated UI only through human-authored, operator-gated wrappers — and what that buys you in safety, caching, and LLM reliability.","body":"---\ntitle: Gadgets — deterministic islands\ndescription: Why third-party capability enters generated UI only through human-authored, operator-gated wrappers — and what that buys you in safety, caching, and LLM reliability.\n---\n\nA generated interface is LLM-written React. That works for layout, forms, and state — but the moment a UI needs a real map, a checkout flow, or the camera, \"generate it\" stops being an option. Nobody wants an LLM improvising a payments integration, and no model reliably reinvents Leaflet's lifecycle quirks on demand.\n\nGadgets are how GGUI resolves that tension: **the LLM composes third-party capability but never authors it.** A gadget is a human-written, versioned, integrity-pinned wrapper around a browser library, exposed to generated code as a React hook with one uniform contract. The generated component calls `useLeafletMap(...)` the same way it calls `useState` — everything inside that hook is deterministic, reviewed code.\n\n## One pattern, learned once\n\nEvery third-party library enters the system through the same factory ([`createGguiGadget`](/sdk/gadgets/)), so every gadget presents the same hook shape: call it with options, get back `{ value, status, start, … }`. The generator never sees raw library APIs — not Leaflet's imperative `L.map()` lifecycle, not Stripe's redirect dance. It sees one hook convention, which means:\n\n- **Cold generations get it right more often.** The model pattern-matches against one shape it has seen many times, not N library idioms it has seen unevenly.\n- **A new gadget costs the model nothing to learn.** The wrapper's teaching text — `description` (what), `usage` (when), `example` (a concrete call) — travels with the descriptor, and the generator reads it at synthesis time. Docs for the LLM are part of the spec, not an afterthought: a wrapper without them is rejected at module load.\n\n## Two symmetric capability surfaces\n\nGadgets are the renderer-side half of a symmetric design ([glossary](/glossary/#capabilities)):\n\n|         | Renderer                     | Agent                     |\n| ------- | ---------------------------- | ------------------------- |\n| Unit    | **gadget**                   | **tool**                  |\n| Catalog | `clientCapabilities.gadgets` | `agentCapabilities.tools` |\n| Gives   | something to render with     | something to act with     |\n\nBoth catalogs are declared per app and bounded by the **operator**, not the agent. An agent cannot smuggle a capability into a render by asking for it: a contract may only reference gadgets the operator registered on the app, and a render naming an unregistered one is rejected with `gadget_not_registered` before any code runs.\n\n## The trust chain\n\nGenerated code runs in a sandboxed iframe with a closed Content-Security-Policy. Each gadget widens that sandbox **declaratively, not implicitly**:\n\n- **Provenance** — the wrapper bundle is fetched by exact package + version, and marketplace-published bundles carry a `sha384` subresource-integrity pin stamped at publish time. What loads is byte-identical to what was published.\n- **Network** — a gadget declares the origins it talks to (`connect: [\"https://tile.openstreetmap.org\"]`); the renderer unions exactly those into the iframe's CSP. A map gadget can fetch tiles; it cannot phone anywhere else, and neither can the generated code around it.\n- **Secrets** — wrappers that need a token (Mapbox, Stripe) declare the `GGUI_PUBLIC_APP_*` keys they consume, and the operator supplies values through the app's public-env channel. Keys never appear in generated code.\n\nThe result: the only non-generated code in a render is code an operator explicitly registered, at a pinned version, with a declared network footprint.\n\n## Why versioning feeds the cache\n\nA gadget's `version` is part of the [blueprint cache key](/concepts/blueprints-and-the-cache/). Bump a wrapper and every cached generation built against the old version stops matching automatically — no stale UI silently calling a changed hook contract. This is the same discipline that makes blueprint reuse safe in general: the cache key captures everything the compiled component depends on.\n\n## The standard library\n\nBrowser capabilities that need no third-party code ship as **STDLIB hooks** — `useGeolocation`, `useCamera`, `useClipboardWrite`, `useClipboardPaste`, `useNotifications`, `useFilePicker`, `useMicrophone`. They follow the same hook contract but require no operator registration; the generator can reach for them whenever the ask calls for one.\n\n## What a gadget is not\n\n- **Not a template.** A gadget renders one capability (a map, a chart, a checkout); the generated component decides where it sits, what feeds it, and what happens around it. Composition stays generative; capability stays deterministic.\n- **Not agent-side.** Gadgets never execute in the agent's process and never see the conversation. Anything the agent should _do_ is a tool.\n- **Not a behavior escape hatch.** The contract describes data flowing between agent and UI; gadgets extend what the _component code_ can do. Neither is a channel for smuggling the other — see [Sessions & the event model](/concepts/sessions-and-the-event-model/) for the placement rule.\n\n## Next\n\n- **[Gadgets SDK](/sdk/gadgets/)** — author a wrapper: the `createGguiGadget` factory, teaching-text budgets, bundle + types publishing, operator registration.\n- **[Marketplace](/hosted/marketplace/)** — publish and install gadgets across apps.\n- **[Blueprints and the cache](/concepts/blueprints-and-the-cache/)** — how versioned capability feeds exact-match reuse."},{"path":"/concepts/rehydration.md","title":"Rehydration","description":"Why an old GGUI card in chat history still works — how a resources/read on an evicted locator re-mints a live session with the same identity and a fresh channel token.","body":"---\ntitle: Rehydration\ndescription: Why an old GGUI card in chat history still works — how a resources/read on an evicted locator re-mints a live session with the same identity and a fresh channel token.\n---\n\nScroll back far enough in most agent chats and the interactive parts are dead. A form that submitted last month is now a grey rectangle, or a spinner that never resolves, because the thing behind it — a session, a socket, a signed URL — expired long ago. The transcript keeps the picture and loses the mechanism.\n\nGGUI is built so that does not happen. An old card is not a screenshot of a render; it is a **locator**, and reading the locator again is what brings the render back.\n\n## What a read does\n\nA GGUI render is addressed by an MCP-Apps resource URI:\n\n```\nui://ggui/render/<sessionId>[/<blueprintKey>][#<epoch>]\n```\n\nWhen a host mounts a card from history, it issues an authenticated `resources/read` on that URI. The server tries three resolutions in order, and any of them can produce a live mount:\n\n| Order | Resolution                                                                                                                                                  |\n| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| 1     | **The render row itself**, when it is still there. A row past its expiry is put back into service, not refused.                                             |\n| 2     | **A re-mint from the durable identity record**, when the row is gone.                                                                                       |\n| 3     | **The blueprint registry**, keyed by the locator's own `blueprintKey` — the original card with authoring-time defaults, not the state the render last held. |\n\nThe re-mint is the interesting one. The server reads the identity record for that `sessionId`, resolves the blueprint it names, pulls the compiled component body from content-addressed storage, and commits a fresh render row — **same `sessionId`, same `createdAt`, same props** — then mints a new `wsToken` for it. The host gets back a normal live mount. Nothing in the response says \"this was resurrected\", because from the protocol's side nothing was: the identity is continuous.\n\nThe event ledger resumes rather than restarts. The identity record carries the render's `eventSequence` at its last commit, and the re-minted row seeds its ledger above that floor, so a reader still holding a cursor from before the eviction sees new events instead of filtering them out as already-seen. Events appended between that last commit and the eviction are not reflected — the record samples the sequence at commit — so sequence reuse is narrowed to that window rather than eliminated.\n\n## What persists, what is re-minted\n\nDurable, written once and kept:\n\n- **The render row** — for as long as its retention allows (see below).\n- **The identity record** — one row per delivered render, keyed by `sessionId`: owning app, owning user, the blueprint id and the contract/variant keys, the props at the last commit, and the sequence floor. It deliberately carries no richer identity block than `userId`, because it is retained for as long as the render is addressable.\n- **The blueprint row and its body** — blueprints carry no TTL, and bodies are content-addressed in object storage so many renders share one.\n\nRe-minted per read, never stored:\n\n- **The `wsToken`** and its `wsUrl`, plus the token-bearing polling and SSE URLs. Tokens are short-lived by design; a card from six months ago gets a token minted at the moment you open it.\n- **The code URL** and its strict-CSP module twin.\n- **The presentation sidecars** — gadget descriptors and the theme overlay are re-resolved from the _current_ app record rather than snapshotted, so a rehydrated render mounts under today's configuration. These degrade quietly: a metadata lookup having a bad moment costs the render its theme, never its rehydrate.\n\nOne thing the re-mint refuses to do: substitute authoring-time defaults for props the record does not have. A record with no props describes a render that had none, and it is given none. Nothing repopulates a defaults-booted card afterwards — props travel the session channel, and no agent turn runs at rehydration — so plausible-looking wrong state would sit there indefinitely.\n\n## The retention posture that makes it work\n\nRehydration is only worth building if the records are still around. On hosted ggui:\n\n- **The default render TTL is effectively indefinite** — 100 years, so `resources/read` on an old card finds the live row rather than falling through to the registry. One knob drives every lifecycle site: the wire-level `expiresAt`, the row's TTL stamps, and the sliding extensions on heartbeat and consume.\n- **Resurrection can be capped.** A deployment running short TTLs can bound total render lifetime, so neither the expired-row extension nor a re-mint's fresh commit pushes expiry past `createdAt` plus that cap. It is unset by default, which is the right pairing with indefinite retention.\n- **Deletion is by path, not by schedule.** Account erasure removes identity records by primary key alongside the renders they name; deleting an app removes its saved blueprints, its render identity rows and the reuse-cache blueprint rows it owns, but not its render records, which are deleted with your account or by an operator on request; and an operator takedown purges a blueprint body by content hash and writes a tombstone — there is no un-takedown operation.\n\nThe full statement, including what survives an account deletion on purpose, is on [Trust & security](/hosted/trust/#deletion--retention).\n\n## What the host has to do\n\nNothing special. Rehydration rides the standard MCP `resources/read` path — there is no ggui-specific \"reopen\" call, no extra capability to advertise beyond `io.modelcontextprotocol/ui`, and no separate endpoint. A host that can mount a fresh render can mount an old one with the same code.\n\nTwo details are worth getting right:\n\n- **Read the per-render locator, not the declaration.** The per-render resource inlines the bootstrap slice, so an old card paints with no host relay. The declaration-level static shell paints too, but only through the host: it needs the forwarded tool result, and when that result carries only the locator, one host-proxied `resources/read` of it (the [read-plane door](/protocol/bootstrap-handshake/#the-read-plane-door)).\n- **Parse for the optional second segment.** Both the single-segment and two-segment forms resolve; the older single-segment shape stays registered so pre-resume-contract cards still in chat history rehydrate. A `#<epoch>` fragment pins an immutable record, and a pinned read serves identical content forever.\n\n## Honest limits\n\nRehydration is not resurrection from nothing. Every failure is a typed JSON-RPC error rather than a shell that never paints:\n\n- **`NOT_FOUND` (`-32002`)** — nothing resolved the locator. It is also the answer for a caller who is _not allowed_ to read a locator that does resolve, and the two are byte-identical by construction: the projection substitutes a constant message and drops any detail. A distinguishable refusal would turn this read into an oracle for the existence of other people's renders, so the equality is a security property rather than a courtesy.\n- **`NOT_SUPPORTED` (`-32006`)** — the same two cases on a server that keeps no durable substrate, where an evicted locator can never come back. It replaces `NOT_FOUND` wholesale on such a server, for the same anti-oracle reason. A server that binds the substrate never emits it.\n- **`BLUEPRINT_UNRESOLVABLE` (`-32006`)** — a record named the render, but its component is gone: taken down, purged with a retired app, or collected as an orphaned body. The `detail` field names which link broke.\n- **`NOT_MOUNTABLE` (`-32006`)** — something resolved but nothing mountable came of it: no delivery channel wired, or a generation that never committed a component.\n\nAnd the access boundary holds through rehydration exactly as it does on a fresh render. The read gate fails closed without a request context, requires the row's app to match the caller's, and binds an end-user caller to the row's subject. A render whose app was deleted, whose owner erased their account, or whose connector key no longer authenticates is not reachable by anyone — that is the retention story working, not a gap in it.\n\nThe behavior on this page is arbitrated by the [conformance kit](/protocol/conformance/), which ships a `resources/read` binding driver: any successful `contents` result is a live mount, and every other outcome is one of the four codes above."},{"path":"/concepts/sessions-and-the-event-model.md","title":"Sessions and the event model","description":"What a GguiSession is, how the two inbound contract specs split, and why the interaction loop is driven by the agent rather than the server.","body":"---\ntitle: Sessions and the event model\ndescription: What a GguiSession is, how the two inbound contract specs split, and why the interaction loop is driven by the agent rather than the server.\n---\n\nEvery GGUI exchange runs on two ideas: a **GguiSession** — one rendered UI with a stable id — and a **placement rule** that decides where each thing the user does shows up on the wire. Get both right and the loop is small: render, consume, react, repaint.\n\nThis page is conceptual. The wire shapes live in [MCP Protocol](/api/mcp-protocol/) and [Envelopes](/protocol/envelopes/); nothing is duplicated here.\n\n## A GguiSession is one rendered UI\n\nA GguiSession is minted server-side inside `ggui_render` — one per UI emission — and keyed by a stable `sessionId`. It carries the compiled component, the props the agent rendered, and the contract that pins the wire surface. The server is the authority on its state, not the agent.\n\nThere is no conversation-level session object. Each `ggui_handshake` → `ggui_render` pair mints a fresh `sessionId`; sibling cards inside the same host chat are grouped through the `_meta[\"ai.ggui/host-session\"]` slice, captured once at creation. Your agent never threads a conversation id.\n\n→ [Glossary → GguiSession](/glossary/#gguisession-a-render)\n\n## Two inbound specs, one test\n\nA contract has four specs. Two of them are inbound — they carry what the UI tells the agent's side:\n\n| Spec          | Direction              | Cardinality            | Drives an agent turn |\n| ------------- | ---------------------- | ---------------------- | -------------------- |\n| `actionSpec`  | UI → agent             | discrete events        | Yes, every entry     |\n| `contextSpec` | UI → server (mirrored) | state, last-write-wins | No, never            |\n\nThe placement test is one question:\n\n> Does this thing need the agent's next-turn reasoning?\n\nYes → `actionSpec`. No → `contextSpec`. There is no third inbound category, and no flag that softens one into the other. If you reach for `terminal: false` on an action to stop it waking the agent, the thing you are describing is state.\n\n### Worked example\n\nA feedback card after a support chat. Three things happen in it, and each lands somewhere different:\n\n```ts\n// contract sketch — the two inbound specs\nactionSpec: {\n  submit: {\n    label: \"Send feedback\",\n    schema: {\n      type: \"object\",\n      properties: { rating: { type: \"number\" }, comments: { type: \"string\" } },\n    },\n  },\n},\ncontextSpec: {\n  category: { schema: { type: \"string\" }, default: \"all\" },\n  draft: { schema: { type: \"string\" }, default: \"\" },\n}\n```\n\n- **The user presses Send.** That is an action. The agent must read the payload and decide what happens next, so `submit` lives on `actionSpec` and reaches the consume pipe as its own event.\n- **The user switches the category filter.** That is context. The agent does not react per switch, but when it next does work it wants to know what the user was looking at. It lives on `contextSpec` and is mirrored continuously.\n- **The card scrolls the newest comment into view.** That is neither. Nothing about it crosses the wire, so it is not on the contract at all — it is behavior, written into the generated component code.\n\nThe third bullet is the split the placement rule sits inside: **the contract describes data flow; the component code describes behavior.** Scroll, focus, debounce, toast, animation, clipboard writes and local drafts are component decisions. Put them on the contract and every renderer has to honor them identically, and two contracts for the same logical UI stop hashing the same — which is how blueprint reuse fragments.\n\nIf the agent genuinely must know that a behavior happened, the _signal_ becomes an action or a context slot; the _mechanic_ stays in component code.\n\n## The loop is agent-driven\n\nActions do not call your tools. The server appends each gesture to a render-scoped pipe, and your agent drains it by calling `ggui_consume({ sessionId })` — a long-poll that returns on the first event or at timeout, with consume-once semantics.\n\nEach row carries the action key that fired, its validated payload, and `uiContext` — the `contextSpec` snapshot taken **at the moment the user acted**, not whatever the state had drifted to by the time consume returned. That is why context needs no event channel of its own: it rides along with the action that woke you.\n\nTwo properties follow from the loop being yours:\n\n- **`nextStep` is advisory.** An `actionSpec` entry may name a tool to run next. Implementations must treat it as a hint; the agent owns the call decision.\n- **There is one routing model.** A deployment with no agent attached queues events on the same pipe until one attaches and drains them. The server never invokes a tool on the user's behalf.\n\nExit the loop when you have the events you need — that is the real terminal condition. `status: \"expired\"` is the other exit and only fires when a render's TTL actually elapses.\n\n→ [`ggui_consume`](/api/mcp-protocol/#ggui_consume)\n\n## Amend repaints, update writes history\n\nAfter you react to an event you usually want the card to reflect it. Two tools, and the choice is about the transcript, not the mechanics:\n\n- **`ggui_amend`** repaints the already-mounted card in place. No new card appears, the history number does not advance, and scroll position, focus and uncommitted input survive. This is the default move in the gesture loop: consume → domain tool → amend → consume again. Skipping the amend is the most common wire-compliance bug.\n- **`ggui_update`** mints the updated state as a new card. The epoch advances by one and the previous card freezes as a history record, so the transcript stays an honest account of what the user actually saw. Use it for milestones worth showing.\n\nNeither changes the contract. When the surface itself must change shape, handshake and render a fresh session.\n\n→ [`ggui_amend` and `ggui_update`](/api/mcp-protocol/#ggui_update)\n\n## How long a session lives\n\nThere is no close ceremony, because there is nothing to close. When an exchange is over, your agent stops addressing that `sessionId`.\n\nOn hosted GGUI the default retention is effectively indefinite, so reopening an old card is the expected path rather than a lucky one. There is no per-render time limit, and self-hosted deployments set their own default.\n\nBecause the record is durable, a host that reads the same `ui://` locator later gets the view back. If the live row has been evicted, the read re-mints it — from the stored identity record when the deployment keeps one, or from the blueprint named in the locator, which returns the original card with its default props rather than a typed failure. Old cards rehydrate; they do not go stale.\n\n→ [Render locator grammar](/api/mcp-apps/#render-locator-grammar) for the URI shape, including the epoch pin\n\n## See also\n\n- [How GGUI works](/how-it-works/) — the same lifecycle as a narrative walkthrough\n- [MCP Protocol](/api/mcp-protocol/) — every tool's input and output\n- [Envelopes](/protocol/envelopes/) — live-channel wire reference\n- [Glossary](/glossary/) — term lookup"},{"path":"/concepts/why-generative-ui.md","title":"Why generative UI","description":"The technical argument for letting an agent render interfaces instead of answering in prose — what generation buys, what the typed action payload tells you, and where generative UI is the wrong tool.","body":"---\ntitle: Why generative UI\ndescription: The technical argument for letting an agent render interfaces instead of answering in prose — what generation buys, what the typed action payload tells you, and where generative UI is the wrong tool.\n---\n\nYour agent already works. The question this page answers is narrower: should it render an interface, or keep answering in text? The case for rendering is not that UI looks better. It is that a form returns a **typed payload** where prose returns a sentence you have to parse, and that generating the form costs you no frontend.\n\n## The problem: prose round-trips\n\nAn agent that only speaks has one way to collect structured input — ask, read the reply, guess what the user meant, ask again. Every field is a turn, every turn is an LLM call, and the answer arrives as free text that nothing validated.\n\nGGUI replaces that loop with one exchange. The agent declares a contract; the user fills the interface; the gesture comes back as a validated payload. The server checks the submitted data against the contract's `actionSpec` before your agent ever sees it (see [How GGUI works](/how-it-works/#3-interact--the-user-fills-the-ui)). Five prose turns collapse into one render and one drain.\n\n## What generation buys\n\n**One contract, many surfaces.** A render is an MCP-Apps resource (`ui://ggui/render/<id>`), not a page you deploy. The same contract mounts inline in Claude Desktop and claude.ai, in your own React web app through `<AppRenderer>`, and in React Native through `<McpAppIframe>` — see [React host helpers](/host-helpers/react/) and [React Native host helpers](/host-helpers/react-native/). You describe the data once; the surface is the host's problem.\n\n**No frontend build.** There is no component to author, no route to add, no deploy to ship a screen your agent needed for the first time this morning. The generator takes the contract — `PropsSpec` + `ActionSpec` + `StreamSpec` + `ContextSpec` — and returns a compiled, contract-typed React module ([UI Generator](/architecture/ui-generator/)).\n\n**Blueprint reuse makes repeat renders cheap.** GGUI is blueprint-first: its reuse layer, the semantic cache for generative UI, runs before generating. An exact canonical-key lookup is deterministic and calls no model at all; when that misses, a fast reranking model chooses among cached candidates, and only a miss on both falls through to a cold generation that then caches its own result. On a cache hit the render is served with **zero generation calls** ([generation pipeline](/architecture/overview/#generation-pipeline)). The more an app reuses contract shapes, the more of its traffic takes the matched path.\n\n**Quality is measured, not asserted.** Generation quality runs nightly across a three-tier model matrix and is scored by a three-provider judge panel at temperature 0, with a pass threshold of 70 and the per-cell spread published alongside the mean. The methodology, the corpus, and how to reproduce a run are in [Benchmark methodology](/architecture/benchmarks/).\n\n## What you learn\n\nThis is the part that is easy to undersell. Web analytics infers intent from behavior — clicks, scroll depth, funnel drop-off, a session replay you watch and interpret. A GGUI action does not need interpreting, because the user's intent is the wire format.\n\nEvery gesture your agent drains from `ggui_consume` is a `ConsumeEventEntry`:\n\n| Field        | What it tells you                                                                    |\n| ------------ | ------------------------------------------------------------------------------------ |\n| `intent`     | Which `actionSpec` entry the user fired — the named thing they chose to do           |\n| `actionData` | The typed payload, already validated against `actionSpec[intent].schema`             |\n| `uiContext`  | The contract's `contextSpec` slot values snapshotted at the instant of the gesture   |\n| `actionId`   | An 8-hex correlation id matching the iframe's toast key and the server's `drain_ack` |\n| `firedAt`    | ISO 8601 timestamp of the gesture, from the iframe                                   |\n\nThat is ground truth about what a person decided, not an inference about what they probably meant. It arrives in your agent's own loop, so you can act on it in the same turn — and store it, aggregate it, or feed it back into the next render however you like.\n\n:::caution[Vision — not shipped]\nThere is no aggregate analytics surface in the GGUI console today. The console gives you the per-app blueprint library, gadgets, keys, theme, marketplace, and a credits ledger with per-render charges — not funnels, cohorts, or an action-event explorer. If you want aggregates over gestures, collect the `ggui_consume` rows yourself. A first-party analytics view is a direction, not a product you can switch on.\n:::\n\n## The honest limits\n\n- **Cold generation is not instant.** A render that misses the cache runs the generator and takes tens of seconds ([How GGUI works](/how-it-works/#2-render--the-ui-gets-generated-or-matched)). A cache hit removes the generation call, which is a real cost saving — but as measured on the current warm path it does not yet buy you a proportional latency saving. Design the first render of a new shape as something the user is willing to wait a beat for.\n- **The code was written by a model, and it runs in a sandbox.** Generated components mount in an iframe with `allow-scripts allow-forms` and deliberately without `allow-same-origin`, so the frame cannot read the embedding page; capabilities like camera or clipboard-write are granted only when the contract asked for them. Inside a third-party host, that host owns the sandbox and its CSP, and that surface is opaque to ggui. Read [Trust & security](/hosted/trust/) before you decide this is acceptable for your data.\n- **Inline mounting depends on the host.** A host that does not advertise the MCP Apps capability still gets a working `ggui_render` — it just cannot mount the resource inline ([Connect other MCP hosts](/clients/connect-other-hosts/#things-every-host-needs)).\n- **Generation is not deterministic.** Two cold generations of the same intent are not guaranteed to produce the same component. Determinism comes from the exact-key blueprint path, not from the model.\n\n## When not to use it\n\nGenerative UI is the wrong tool when the interface itself is the product:\n\n- **Brand-critical static surfaces.** Marketing pages, pricing tables, anything a designer signed off on pixel by pixel. Build those; GGUI has nothing to add.\n- **Pixel-exact or regulated flows.** If a screen must be byte-stable across renders — a legal disclosure, a certified checkout, a screen an auditor compares against a spec — you want a committed component, not a generated one.\n- **One screen, used constantly, never varying.** If your agent renders the same shape a thousand times a day, the generation is pure overhead. Register that shape as a blueprint so the deterministic exact-key path serves it every time, or build it as a normal component and let your agent link to it.\n- **No structured decision to collect.** If the user's next move really is a sentence, prose is the right interface. UI earns its place when there is a choice to make, a field to fill, or state to watch.\n\nThe test is simple: does the turn end with the user _deciding_ something? If yes, a render turns that decision into typed data. If no, keep talking.\n\n## Next\n\n- [How GGUI works](/how-it-works/) — the five moments of an exchange, end to end.\n- [Build an agent on hosted GGUI](/quickstart/hosted-agent/) — the shortest path to a first render against `https://mcp.ggui.ai`.\n- [Trust & security](/hosted/trust/) — what is stored, what reaches a model, and where the sandbox ends."},{"path":"/cookbook/auth-gated-ui.md","title":"Auth-Gated UI","description":"Gate GGUI renders behind your app's auth with a bearer token on useMcpAppsChat — and understand why OAuth tool-consent is the client's job, not GGUI's.","body":"---\ntitle: Auth-Gated UI\ndescription: Gate GGUI renders behind your app's auth with a bearer token on useMcpAppsChat — and understand why OAuth tool-consent is the client's job, not GGUI's.\n---\n\nGGUI is a renderer, not an identity provider. Your app already knows who the user is. There are **two separate auth concerns**, and only the first is yours to wire:\n\n1. **Authenticating your app → agent backend.** Every request `useMcpAppsChat` makes (prompt POST, snapshot GET, the iframe → MCP tool-call relay) carries a bearer token your app supplies. The backend gates chat ownership on the principal that token identifies. **This is the recipe below.**\n2. **OAuth consent for tools the _agent_ calls.** When a tool the agent calls needs the end-user to authorize a third-party service (Google, Slack), that consent flow is **not** GGUI's responsibility — it belongs to MCP's OAuth standard, the hosting agent, and your client app. GGUI only surfaces an optional signal. See [OAuth tool-consent is the client's job](#oauth-tool-consent-is-the-clients-job).\n\n## Gate the chat behind a bearer token\n\n`useMcpAppsChat` takes two auth hooks:\n\n- **`getAuthToken()`** — called before every request; return the bearer token to send as `Authorization: Bearer <token>` (or `undefined` for none).\n- **`onUnauthenticated()`** — called on a `401`; refresh/re-mint your token, return `true` to retry the request once, `false` to surface the error.\n\nThe [`ggui-basic-web`](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) sample uses these for a **guest-token** flow: it mints an anonymous token from the backend's `POST /auth/guest` mount, caches it, and re-mints on `401`. Swap that mint for your own user-session token and the same wiring gates renders behind a signed-in user:\n\n```tsx\nimport { useCallback, useRef } from \"react\";\nimport { useMcpAppsChat } from \"@ggui-ai/mcp-apps-react/chat-helpers\";\n\nfunction Chat({ agentEndpoint }: { agentEndpoint: string }) {\n  const { user, getAccessToken, refreshSession } = useAuth(); // your auth provider\n\n  // Read the current token per-request (kept in a ref so the callback\n  // always sees the latest after a refresh).\n  const tokenRef = useRef<string | undefined>(getAccessToken());\n  const getAuthToken = useCallback(() => tokenRef.current, []);\n\n  // 401 → refresh once, then retry. Return false to give up.\n  const onUnauthenticated = useCallback(async () => {\n    const fresh = await refreshSession();\n    if (!fresh) return false;\n    tokenRef.current = fresh;\n    return true;\n  }, [refreshSession]);\n\n  const { entries, sessions, send, handleAppMessage } = useMcpAppsChat({\n    chatEndpoint: `${agentEndpoint}/agent`,\n    snapshotEndpoint: `${agentEndpoint}/agent`,\n    getAuthToken,\n    onUnauthenticated,\n  });\n\n  // render `entries` + mount `sessions` with <AppRenderer> as usual\n}\n```\n\nThe token rides every hook request automatically. The backend authenticates it and scopes the conversation (its `chatId` snapshot + resume state) to that principal — clear the session and you're a different principal with different chats.\n\n### Mount-time gate\n\nDon't open the chat until your provider says the user is signed in — a plain conditional render, exactly how the sample guards on `guestTokenReady`:\n\n```tsx\nfunction App({ agentEndpoint }: { agentEndpoint: string }) {\n  const { isAuthenticated, isLoading } = useAuth();\n  if (isLoading) return <p>Loading…</p>;\n  if (!isAuthenticated) return <LoginPage />;\n  return <Chat agentEndpoint={agentEndpoint} />;\n}\n```\n\n### The tool-call relay carries the token too\n\nWhen the iframe dispatches a tool call, your host relays it to the agent backend's `POST /agent` (`kind: \"tool-call\"`) inside `<AppRenderer onCallTool>`. Send the **same** bearer token on that relay so the MCP call runs as the signed-in user — the sample's `onCallTool` reads `getAuthToken()` and sets `Authorization: Bearer <token>` on the relay fetch. Don't trust anything the iframe puts in the payload as identity; the token on the relay is the credential.\n\n## OAuth tool-consent is the client's job\n\nA different situation: the agent wants to call a tool that needs the **end-user** to authorize a third-party service first (read their Google Calendar, post to Slack). That consent flow is deliberately **outside** the GGUI protocol. It is owned by:\n\n- **MCP's OAuth standard** — the spec defines how an MCP server advertises that a tool needs authorization and how the consent/token exchange happens.\n- **The hosting agent** (e.g. `@anthropic-ai/claude-agent-sdk`) — follows that spec to handle tool authorization.\n- **Your end-user client app** — the surface actually talking to the agent backend pops its own consent UI and drives the redirect. Consent is a client decision, not something GGUI's runtime or protocol renders.\n- **guuey's MCP proxy** — captures an OAuth credential once so multiple agents can reuse it (coming soon — part of the guuey platform, not the open GGUI protocol).\n\nSo GGUI itself renders no consent screen, and its wire carries no signal that one is needed — there is no frame, tool, or observability event through which a consent challenge travels. That is deliberate: the party that invokes the tool is the party that owns the user relationship and the chrome a prompt would render into, so the ceremony lives entirely in your host. See [Error Handling → renderer-side faults](/cookbook/error-handling/#renderer-side-faults-stay-inside-the-iframe) for what the observability channel _does_ carry.\n\n## See also\n\n- [React host helpers](/host-helpers/react/) — `useMcpAppsChat` (`getAuthToken` / `onUnauthenticated`) + `<AppRenderer>`\n- [`ggui-basic-web` sample](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) — the runnable guest-token reference (`Chat.tsx`)\n- [Error Handling](/cookbook/error-handling/) — HTTP `401` and the JSON-RPC auth codes\n- [Build an agent on hosted GGUI](/quickstart/hosted-agent/) — the agent-side handshake → render → consume loop"},{"path":"/cookbook/chat-own-storage.md","title":"Chat with Your Own Storage","description":"Build a ggui-powered chat UI on top of your existing persistence layer using @ggui-ai/mcp-apps-react and the pure helpers in @ggui-ai/mcp-apps-react/chat-helpers.","body":"---\ntitle: Chat with Your Own Storage\ndescription: Build a ggui-powered chat UI on top of your existing persistence layer using @ggui-ai/mcp-apps-react and the pure helpers in @ggui-ai/mcp-apps-react/chat-helpers.\n---\n\nYou own the UI, you own the storage. GGUI gives you the streaming protocol\n(`useInvoke`) plus pure helpers for persistence shape\n(`@ggui-ai/mcp-apps-react/chat-helpers`). Where messages live, how threads are indexed,\nwhat your composer looks like — that's yours.\n\n:::note[Which chat surface is this?]\nGGUI has two consumer chat paths. This recipe is the **invoke-protocol** path: `useInvoke` streams from your agent's `/invoke` endpoint and accumulates `ConversationMessage[]` you persist yourself. It's the right layer when you want full control of message storage. The other path — `useMcpAppsChat` + `<AppRenderer>`, used by the [`ggui-basic-web`](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) sample — hosts MCP-Apps renders as sandboxed iframes and rehydrates from a server snapshot rather than client storage. Pick this one when the storage is yours; pick that one when the server is authoritative.\n:::\n\n## When to use this pattern\n\nPick this pattern when **at least one** holds:\n\n- You already have a persistence layer (Postgres, Firestore, IndexedDB, your\n  Redux store) and don't want a second one.\n- You need full control of the message schema — custom attachments,\n  per-message ACLs, server-side fan-out.\n- You're integrating GGUI into an existing chat surface, not building a new one.\n\nIf none apply and you just want \"a chat UI that works\", reach for\n(Historical note: a bundled `useChatThread` chat-persistence layer once offered the same flow behind a single\nhook with a pluggable `MessageStorageAdapter` and a `ChatThreadProvider`.\n\n## 60-line example\n\nThe file below is the complete working integration — streaming, persistence,\ncard rendering, send — in ~60 lines.\n\n```tsx\nimport { useEffect } from \"react\";\nimport { GguiProvider, useInvoke } from \"@ggui-ai/mcp-apps-react\";\nimport {\n  useRafThrottled,\n  invokeMessageToContentGroups,\n  extractRenderFromToolResult,\n  type ContentGroup,\n} from \"@ggui-ai/mcp-apps-react/chat-helpers\";\n\n// Replace with any storage: localStorage, fetch, IndexedDB, Firestore, …\nconst store: Array<{ threadId: string; group: ContentGroup }> = [];\n\nfunction persist(threadId: string, messages: ReturnType<typeof useInvoke>[\"messages\"]) {\n  const seen = new Set(store.filter((e) => e.threadId === threadId).map((e) => e.group.key));\n  for (const msg of messages) {\n    for (const group of invokeMessageToContentGroups(msg)) {\n      if (!seen.has(group.key)) store.push({ threadId, group });\n    }\n  }\n}\n\nfunction Chat({ threadId, endpointUrl }: { threadId: string; endpointUrl: string }) {\n  const { messages, send, isStreaming } = useInvoke({ endpointUrl });\n  const throttled = useRafThrottled(messages);\n\n  useEffect(() => {\n    persist(threadId, messages);\n  }, [threadId, messages]);\n\n  return (\n    <div>\n      {throttled.map((m) => (\n        <div key={m.id}>\n          <strong>{m.role}: </strong>\n          {m.content.map((b, i) => {\n            if (b.type === \"text\") return <p key={i}>{b.text}</p>;\n            if (b.type === \"tool_result\") {\n              const render = extractRenderFromToolResult(b);\n              return <pre key={i}>{JSON.stringify(render, null, 2)}</pre>;\n            }\n            return null;\n          })}\n        </div>\n      ))}\n      <button\n        disabled={isStreaming}\n        onClick={() => send(\"hello\", { clientMessageId: crypto.randomUUID() })}\n      >\n        Send hello\n      </button>\n    </div>\n  );\n}\n\nexport default function App() {\n  return (\n    <GguiProvider appId=\"demo\">\n      <Chat threadId=\"t1\" endpointUrl=\"http://127.0.0.1:6781\" />\n    </GguiProvider>\n  );\n}\n```\n\n:::tip\nPoint `endpointUrl` at your own self-hosted GGUI server (see\n[self-hosted-registry](/sdk/self-hosted-registry/)) — or, on the hosted\nplatform, at `https://mcp.ggui.ai`.\nThe imports and shapes below are verified against the `@ggui-ai/mcp-apps-react` 0.9.x line.\n:::\n\n## The `ContentGroup` contract\n\n`invokeMessageToContentGroups(message)` splits a finalized invoke message\ninto `ContentGroup`s — the durable unit you persist:\n\n```ts\ninterface ContentGroup {\n  key: string; // `${message.id}-${startBlockIdx}` — see invariant below\n  kind: \"text\" | \"card\" | \"other\";\n  authorRole: \"user\" | \"agent\";\n  blocks: ContentBlock[]; // a contiguous run of text, or a tool_use + tool_result pair\n  cardSnapshot: unknown | null; // frozen GguiSession for kind=\"card\"\n  textPreview: string; // ~160-char preview for chat-list tiles\n}\n```\n\n**The `key` invariant.** `key` is deterministic from `message.id` plus the\nblock index where the group starts. Two consequences:\n\n1. **Idempotency.** Re-persisting the same group with the same key is a\n   no-op. If your storage uses `key` as primary key (recommended), the\n   `persist()` loop above can run after every token delta without\n   producing duplicates.\n2. **Streaming messages are excluded.** A message whose `isStreaming` is\n   `true` returns `[]` — groups appear only after the turn finalizes.\n   That's why `persist()` doesn't need a separate \"on end_turn\" callback.\n\n## Reloading a thread\n\nOn thread reopen, rebuild the `ConversationMessage[]` your store remembers\nand seed the hook via `initialMessages`. `contentGroupsToConversationMessages`\ncollapses groups sharing the same `message.id` prefix back into one message:\n\n```tsx\nimport { contentGroupsToConversationMessages } from \"@ggui-ai/mcp-apps-react/chat-helpers\";\n\nfunction useSeededInvoke(threadId: string, endpointUrl: string) {\n  // Resolve before render — useInvoke captures initialMessages on mount.\n  const groups = store.filter((e) => e.threadId === threadId).map((e) => e.group);\n  const seed = contentGroupsToConversationMessages(groups);\n  return useInvoke({ endpointUrl, initialMessages: seed });\n}\n```\n\n`initialMessages` is a **seed on mount**. Changing it on re-render does\n_not_ reset hook state — intentional; the hook owns the conversation\nafter mount. To switch threads, unmount the `<Chat>` subtree (set\n`key={threadId}`) and let the new instance seed from that thread's store.\n\n## Why `send({ clientMessageId })` matters\n\n`useInvoke` accepts an optional `clientMessageId` the caller controls:\n\n```tsx\nsend(\"hello\", { clientMessageId: crypto.randomUUID() });\n```\n\nThe rendered user message's `id` becomes that value, which buys:\n\n- **Retry without duplicates.** Retrying the same send after a network\n  failure produces the same `clientMessageId` → same `ContentGroup.key` →\n  the outbox is idempotent by construction.\n- **Cross-device continuity.** Persist user messages optimistically on the\n  sending device; when the agent turn later replays on another device, the\n  same `clientMessageId` collapses both into one thread entry.\n\nWithout `clientMessageId`, `useInvoke` falls back to a random `user_<hex>`\nid — fine for ephemeral chats, wrong for durable storage.\n\n## What you still have to do\n\nThe helpers stop at _shape_. You still own:\n\n- **Transport to storage.** `store.push(...)` above is an in-memory array\n  for brevity. Replace it with `fetch('/persist')`, an IndexedDB write, a\n  Firestore batch — whatever fits your stack.\n- **Thread indexing.** `ContentGroup.textPreview` is the building block\n  for chat-list tiles; wiring it into a sidebar is yours.\n- **Reconnect + resume.** `useInvoke` does not replay an interrupted\n  stream. If the page reloads mid-turn, the in-flight assistant message is\n  lost — only finalized groups persist. a heavier persistence layer\n  closes this gap.\n\nWhen those boundaries start to hurt, extend your adapter — the package deliberately ships no persistence framework (owner ruling 2026-08-13): your storage, your rules, same\nprimitives, plus a `MessageStorageAdapter` interface, a\n`ChatThreadProvider`, the outbox, seed-on-reopen wiring, and the\noptimistic-send UX.\n\n## See also\n\n- [React host helpers reference](/host-helpers/react/) — `useInvoke`, `chat-helpers`, and the `<AppRenderer>` render host\n- [Glossary](/glossary/) — gadget, tool, blueprint, render\n- [Self-hosted registry](/sdk/self-hosted-registry/) — point\n  `endpointUrl` at your own server"},{"path":"/cookbook/custom-theming.md","title":"Custom Theming","description":"Match ggui-generated UIs to your brand with two-layer theming — design-token CSS variables for your host chrome, plus the operator's ggui.json theme preset for the generated iframe content.","body":"---\ntitle: Custom Theming\ndescription: Match ggui-generated UIs to your brand with two-layer theming — design-token CSS variables for your host chrome, plus the operator's ggui.json theme preset for the generated iframe content.\n---\n\n:::tip[Using hosted GGUI from Claude?]\nYou don't need any of the code on this page. Open [console.ggui.ai](https://console.ggui.ai), pick your app, and open its **Theme** tab: choose a preset and a light or dark mode, and save. Cards you get through the `mcp.ggui.ai` connector wear your **default app's** theme. A new account's app has no theme saved, so its cards use GGUI's default until you pick one there.\n:::\n\nGGUI theming has **two independent layers**, and they live on opposite sides of the iframe boundary. Keep them straight:\n\n1. **Your host chrome** — the React app _around_ the render (the chat panel, the surrounding page). You theme this with `@ggui-ai/design` tokens: a `<ThemeProvider>` injects `--ggui-*` CSS variables on `:root` and your CSS references them.\n2. **The generated UI inside the iframe** — the component the agent rendered. You never style this from the host (it's sandboxed on a different origin). Its palette comes from the operator's `ggui.json` `theme` preset, resolved per render and merged into the scoped block of `--ggui-*` variables the components read. Its light/dark mode belongs to the embedding host: when the host announces a mode, that mode wins.\n\nThe two are matched by convention: the [`ggui-basic-web`](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) sample picks the **same** preset for its host chrome that the demo `ggui.json` picks for the iframe, so the chat shell and the generated card share one palette.\n\nFor the full token reference (palettes, scales, semantic colors), see [Design Tokens](/design/tokens/).\n\n## Layer 1 — host chrome via `<ThemeProvider>`\n\n`<ThemeProvider>` (from `@ggui-ai/design/themes`) takes a raw [DTCG](https://design-tokens.github.io/community-group/format/) token tree and a color mode, injects the theme's `--ggui-*` CSS variables on `:root` (the set in the [token reference](#token-reference) below), and wires the base `html, body` font + surface colors. Pull a registered preset with `getRawTheme(id, mode)`:\n\n```tsx\nimport { ThemeProvider, getRawTheme } from \"@ggui-ai/design/themes\";\n\n// Resolve the raw DtcgTheme token tree for a registered preset.\nconst INDIGO_DARK = getRawTheme(\"indigo\", \"dark\");\n\nfunction App() {\n  return (\n    <ThemeProvider theme={INDIGO_DARK} mode=\"dark\">\n      {/* Your chat panel / page chrome. Every CSS rule that references\n          a --ggui-* variable now resolves against the indigo/dark tokens. */}\n    </ThemeProvider>\n  );\n}\n```\n\nYour stylesheet then references the injected variables — the shape the sample's `globals.css` uses to map semantic chat-shell roles onto GGUI tokens. Always supply the hardcoded fallback, as the sample does: it is what paints when no theme has been injected yet.\n\n```css\n:root {\n  --bg-app: var(--ggui-color-ground, #ffffff);\n  --bg-chat: var(--ggui-color-sunken, #f3f4f6);\n  --accent: var(--ggui-color-primary-500, #0ea5e9);\n  --fg: var(--ggui-color-onGround, #111827);\n  --fg-muted: var(--ggui-color-onSunken, #6b7280);\n}\n\n.chat {\n  background: var(--bg-chat);\n  color: var(--fg);\n  font-family: var(--ggui-font-family-sans);\n}\n```\n\n:::tip[Embedded contexts]\nWhen GGUI renders inside a host that draws its own chrome around your content (a claude.ai chat bubble, Claude Desktop), pass `transparent` so the body background drops out and the host surface shows through: `<ThemeProvider theme={…} mode=\"dark\" transparent>`. Token-level `--ggui-color-ground` stays opaque, so cards and panels inside still paint their own surfaces.\n:::\n\n### Discovering presets\n\n`@ggui-ai/design/themes` ships a registry — list what's available at runtime (e.g. to build a theme picker):\n\n```tsx\nimport { getThemeIds, listThemes, getDefaultThemeId } from \"@ggui-ai/design/themes\";\n\ngetThemeIds(); // ['ggui', 'indigo', 'claudic', …] — every registered id\nlistThemes(); // [{ id, name, description, modes }, …] — metadata for a picker\ngetDefaultThemeId(); // 'ggui'\n```\n\n## Layer 2 — generated iframe content via `ggui.json`\n\nYou can't reach into the sandboxed iframe with CSS. Instead, the operator's `ggui.json` `theme` block sets the app's default preset. At each render the server resolves the theme (an operator's live pick in the console, where the deployment offers one → the per-render `themeId` override → the app's `ggui.json` default → the shipped default) and snapshots it onto the GguiSession. The iframe-runtime then merges the theme into the **scoped token block** it mounts inside the render's scope element, which is the block the generated components read. It also copies the variables onto the iframe's `:root`, but that copy only styles the body chrome around the component. Generated components reference the same token variables your host chrome does, so the palette is a runtime overlay, not generated code:\n\n```json\n{\n  \"schema\": \"1\",\n  \"protocol\": \"draft-2026-09-10\",\n  \"app\": { \"slug\": \"my-app\", \"name\": \"My App\" },\n  \"theme\": {\n    \"preset\": \"indigo\",\n    \"mode\": \"dark\"\n  }\n}\n```\n\n`theme` also accepts a bare string preset shorthand (`\"theme\": \"indigo\"`), an `overrides` map of flat dot-path token tweaks on top of a preset (`{ \"preset\": \"indigo\", \"mode\": \"dark\", \"overrides\": { \"color.primary.500\": \"#8b5cf6\" } }` — no custom theme file needed), and a `{ \"file\": \"./theme.json\", \"mode\": \"dark\" }` form pointing at a DTCG theme file. It also accepts `null`. Locally, `\"theme\": null` means the same as leaving `theme` out: the shipped default tokens. On [`ggui deploy`](/cli/deploy/) it clears the app's stored theme, while leaving `theme` out leaves the stored theme untouched. Lint a theme file before serving with `ggui theme validate <path>`.\n\nMatch `theme.preset` here to the preset you pass to `getRawTheme(...)` for `<ThemeProvider>` in your host, and the chat shell and the rendered card render in one cohesive palette — the sample does exactly this.\n\nYou don't need to match `theme.mode` by hand. It is only a default: when the embedding host announces a mode (the MCP Apps `hostContext.theme`), the host's mode wins, so the card follows the host's light/dark switch. From highest to lowest, the mode comes from the host's announced mode, then an operator's live pick in the console, then `ggui.json`'s `theme.mode`, then the mode stored with the app's theme.\n\n### Per-render theme override\n\nThe preset isn't fixed per app. Agents can list the available presets with the `ggui_list_themes` tool (each entry is `{ id, name, description, modes }`) and pick one for a single render via the `themeId` input on `ggui_render` — the override wins over the app default for that render only. An operator's live pick in the console, where the deployment offers one, still outranks it. `App.availableThemeIds` allowlists which presets agents may pick.\n\n## Ad-hoc CSS token overrides\n\nThe token layer is just CSS custom properties with hardcoded fallbacks, so you can also override individual variables without a registered preset — useful for a quick rebrand on top of a chosen theme, or for scoping one subtree.\n\n### Global override\n\nSet tokens on `:root` to restyle every host-chrome surface in the page:\n\n```css\n:root {\n  /* Primary brand color (50 → 900 scale) */\n  --ggui-color-primary-50: #eff6ff;\n  --ggui-color-primary-500: #3b82f6;\n  --ggui-color-primary-600: #2563eb;\n  --ggui-color-primary-900: #1e3a8a;\n\n  /* Typography */\n  --ggui-font-family-sans: \"Inter\", system-ui, sans-serif;\n  --ggui-font-family-mono: \"JetBrains Mono\", ui-monospace, monospace;\n\n  /* Shape + density */\n  --ggui-spacing-md: 16px;\n  --ggui-shape-radius-md: 8px;\n  --ggui-shape-shadow-md: 0 8px 16px -4px rgba(15, 23, 42, 0.1);\n}\n```\n\nThese run on top of whatever `<ThemeProvider>` injected — the provider writes the `:root` block first, your stylesheet's later `:root` rules win on equal specificity.\n\n### Scoped theming\n\nCSS custom properties cascade — wrap any subtree to give it its own accent without leaking to siblings:\n\n```tsx\n<section\n  style={\n    {\n      \"--ggui-color-primary-600\": \"#8b5cf6\",\n      \"--ggui-shape-radius-md\": \"1rem\",\n    } as React.CSSProperties\n  }\n>\n  {/* Everything in here picks up the purple primary + softer corners */}\n</section>\n```\n\n## Token reference\n\n| Category          | Pattern                                                                                                                                                                                                                                                                                                                         | Example                         |\n| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |\n| Colors (primary)  | `--ggui-color-primary-{50-900}`                                                                                                                                                                                                                                                                                                 | `--ggui-color-primary-600`      |\n| Colors (neutral)  | `--ggui-color-neutral-{50,100,200,300,400,500,600,900}`                                                                                                                                                                                                                                                                         | `--ggui-color-neutral-200`      |\n| Colors (status)   | `--ggui-color-{success,warning,info}-{50,100,200,500,700,800}` and `--ggui-color-error-{50,100,200,500,600,700,800}`                                                                                                                                                                                                            | `--ggui-color-success-500`      |\n| Layering roles    | `--ggui-color-{ground,onGround,container,onContainer,elevated,onElevated,sunken,onSunken,outline,outlineVariant}` — `ground` is the canvas, `container` the card, `sunken` the well; `elevated` is derived                                                                                                                      | `--ggui-color-onGround`         |\n| Role pairs        | `--ggui-color-{onPrimary,primaryContainer,onPrimaryContainer,onError,errorContainer,onErrorContainer,tertiaryContainer,onTertiaryContainer}` and the `{success,warning,info}Container` pairs with their `on*` inks                                                                                                              | `--ggui-color-onPrimary`        |\n| Links             | `--ggui-color-{link,inverseLink}`                                                                                                                                                                                                                                                                                               | `--ggui-color-link`             |\n| Typography        | `--ggui-font-family-{sans,heading,mono}`, `--ggui-font-size-{xs,sm,base,lg,xl,2xl,3xl,4xl}`, `--ggui-font-weight-{normal,medium,semibold,bold,heading}`, `--ggui-font-lineHeight-normal`, `--ggui-letter-spacing-heading` — sizes come from the one `font.ramp`; a theme authors `font.{family,weight,ramp,letterSpacing}` only | `--ggui-font-size-lg`           |\n| Spacing           | `--ggui-spacing-{xs,sm,md,lg,xl,2xl}` and the numeric steps `--ggui-spacing-{1,2,3,4,5,6,8,10,12}`                                                                                                                                                                                                                              | `--ggui-spacing-md`             |\n| Shape — radius    | `--ggui-shape-radius-{sm,md,lg,xl,full,control}`                                                                                                                                                                                                                                                                                | `--ggui-shape-radius-lg`        |\n| Shape — shadow    | `--ggui-shape-shadow-{sm,md,lg,xl}`                                                                                                                                                                                                                                                                                             | `--ggui-shape-shadow-md`        |\n| Motion — duration | `--ggui-motion-duration-{fast,base,slow}`                                                                                                                                                                                                                                                                                       | `--ggui-motion-duration-base`   |\n| Motion — easing   | `--ggui-motion-easing-{standard,emphasized,exit}`                                                                                                                                                                                                                                                                               | `--ggui-motion-easing-standard` |\n| Scrim             | `--ggui-scrim-{tint,opacity}` — the tint laid between the host page and the card                                                                                                                                                                                                                                                | `--ggui-scrim-opacity`          |\n\n**Status colors are ramps derived from one anchor** — a theme authors a single `500` for each of `success`, `warning`, `error` and `info`, and the stops listed above, the inks, the containers and the outlines are derived from it. **Layering roles are pairs** — `ground`/`onGround` (the canvas and its text), `container`/`onContainer` (cards), `sunken`/`onSunken` (wells, inputs at rest, code blocks, secondary text) — with `elevated`/`onElevated` derived; use them to keep contrast right across nested backgrounds. **Motion is a bounded tempo override.** A theme may state `motion.duration.{fast,base,slow}` and `motion.easing.{standard,emphasized,exit}`, and since v0.21.0 the card animates at the stated tempo: every design-system transition reads the six `--ggui-motion-*` variables. A theme that states none animates at the shipped tempo. A `theme.json` in the old shape (`surface`/`surfaceVariant`, authored `font.size` / `font.lineHeight`, a free-form `motion.duration` / `motion.easing` ladder such as `instant`…`slower`, `$metadata.fontUrl`) is refused: `ggui serve` refuses to start on it, and `ggui theme validate <path>` names the field.\n\nSee [Design Tokens](/design/tokens/) for live swatches and the complete scale.\n\n## See also\n\n- [Design Tokens](/design/tokens/) — full palette, scales, and live swatches\n- [React host helpers](/host-helpers/react/) — `<AppRenderer>` + `useMcpAppsChat`, the web render-hosting surface\n- [`ggui-basic-web` sample](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) — the runnable reference that matches host chrome to `ggui.json`"},{"path":"/cookbook/error-handling.md","title":"Error Handling","description":"Classify GGUI failures across HTTP, JSON-RPC, and the renderer's ggui:observe channel — and recover from each at its layer.","body":"---\ntitle: Error Handling\ndescription: Classify GGUI failures across HTTP, JSON-RPC, and the renderer's ggui:observe channel — and recover from each at its layer.\n---\n\n:::note[No wrapper SDK]\nGGUI has no client SDK wrapper. Errors arrive on whichever transport you're already using: HTTP status codes from the MCP transport, JSON-RPC error objects inside MCP responses, and typed `error` frames on the live-channel WebSocket. The same patterns apply against `mcp.ggui.ai` and a local `ggui serve` — only the URL and auth header change. See the [self-hosted quickstart](/quickstart/self-hosted/) for the local-first path.\n:::\n\nGGUI surfaces failures at three layers. Handle each at its layer:\n\n1. **HTTP** — the MCP transport returns `401` / `403` / `429` / `5xx` before the JSON-RPC body is even parsed.\n2. **JSON-RPC** — a `tools/call` reaches the server but the server returns a `-32xxx` error code (or a tool-level failure with `isError: true`).\n3. **Live channel** — a failure _after_ a successful render. Action-validation rejections ride the live-channel WebSocket as typed `error` frames carrying `code: 'CONTRACT_VIOLATION'` and surface in the renderer, not the agent; nothing lands on the consume buffer. On the relay (`ggui_runtime_submit_action`) the same failure comes back as a normal tool result, `{ ok: false, code: 'CONTRACT_VIOLATION', message, violations }`, not an `isError` result, and the gesture never reaches `ggui_consume`.\n\n## Layer 1 — HTTP errors from the MCP transport\n\nThese come back as `IsHttpError` / response status from whichever HTTP client your MCP SDK uses. Treat them as transport failures — the server hasn't even looked at your JSON-RPC payload yet.\n\n| Status        | Meaning                             | Retry?                           |\n| ------------- | ----------------------------------- | -------------------------------- |\n| `401`         | Bad / expired API key               | No — fix config                  |\n| `403`         | Key valid but app not authorized    | No — fix config                  |\n| `429`         | Rate-limited (`Retry-After` header) | Yes, after `Retry-After` seconds |\n| `5xx`         | Transient server failure            | Yes, with exponential backoff    |\n| network error | DNS / connection / TLS failure      | Yes, with exponential backoff    |\n\nThe `Retry-After` header (when present) is authoritative — honor it verbatim. See [`/hosted/rate-limits/`](/hosted/rate-limits/) for the 429 response shape (body + header) and a raw-HTTP backoff recipe.\n\n## Layer 2 — JSON-RPC errors from the server\n\nA 200 HTTP response can still carry a JSON-RPC error. The MCP SDKs surface these as thrown errors with a numeric `code`; raw HTTP callers see `{ \"error\": { \"code\": -32xxx, \"message\": \"...\" } }` in the response body.\n\n<!-- ggui:error-codes-abridged — retry-oriented subset: the codes an agent's backoff loop branches on, plus a prose pointer to the `-32010` platform block below. Deliberately not the full table; `/api/mcp-protocol/#error-codes` carries that. -->\n\n| Code     | Name              | When                             | Retry?                |\n| -------- | ----------------- | -------------------------------- | --------------------- |\n| `-32700` | Parse Error       | Invalid JSON in request          | No — fix the call     |\n| `-32600` | Invalid Request   | Not a valid JSON-RPC object      | No — fix the call     |\n| `-32601` | Method Not Found  | Unknown tool name                | No — fix the call     |\n| `-32602` | Invalid Params    | Missing / invalid tool arguments | No — fix the call     |\n| `-32603` | Internal Error    | Server-side failure              | Yes, with backoff     |\n| `-32007` | Unauthorized      | Invalid token or app ID          | No — fix config       |\n| `-32002` | Session Not Found | Session expired or reaped        | Re-handshake + render |\n| `-32003` | App Not Found     | App ID does not exist            | No — fix config       |\n\nPlatform deployments also reserve the `-32010` range: `-32014` (generation overloaded — HTTP `503` with `Retry-After`) is the one live code; `-32010` to `-32013` and `-32020` are retired-reserved and never emitted. Full table with descriptions: [`/api/mcp-protocol/#error-codes`](/api/mcp-protocol/#error-codes).\n\nTool-level failures (the tool ran but returned an error result) come back as a successful JSON-RPC response with `isError: true` on the `tools/call` result content. Inspect `result.content` for the failure detail. **Generation failure is the flagship case**: a failed `ggui_render` returns `isError: true` with schema-conformant `structuredContent` carrying `error: { code, message }` — the closed canonical enum `PRODUCTION_FAILED | VALIDATION_ERROR | NO_PLATFORM_KEY | NO_CREDENTIALS | GENERATION_QUEUE_OVERLOADED` (`-32004` is retired-reserved and never fires). **Branch on `structuredContent.outcome` before you read any code.** Two different outcomes arrive as `isError: true`, and they carry their classification on different fields: a `failed` render carries `error.code`, while a `refused` one carries `refusal.code` and no `error` at all. Matching on `structuredContent.error.code` alone silently misses every refusal and throws away the `fix` and `retry` that make it actionable.\n\nA `failed` render has no `resourceUri` and no `_meta` — do not mount it; the handshake is consumed, so recovery is a fresh `ggui_handshake` (for `PRODUCTION_FAILED`, retrying with a simpler intent is reasonable; `GENERATION_QUEUE_OVERLOADED` means generation never ran at all, so it's safe to retry immediately; the remaining three codes need a config fix first).\n\nA `refused` render is the deployment declining **before it did any work**. Its `structuredContent` is exactly `{ outcome: \"refused\", refusal }` — no `sessionId`, no `cache`, no `error`. Nothing was committed and **the handshake is intact**, so the same `handshakeId` still works once the fix lands. `refusal.fix` names the one recovery step and `refusal.retry` is one of `after-fix` | `next-period` | `later` | `never`. Only retry automatically when the registry row for `refusal.code` names `fixBy: \"caller\"` — otherwise the fix belongs to the app's owner or the operator, and retrying does not perform it. Surface `refusal.fix` instead. See [Refused (pre-generation)](/api/mcp-protocol/#refused-pre-generation).\n\nOther `ggui_*` domain failures surface the same way — an `isError` result whose `content[0].text` leads with the registered code, `<code>: <detail>` (`handshake_not_found: handshakeId \"…\" not found. …`, `session_not_found: GguiSession \"…\" not found. …`), with no `structuredContent` and no `_meta`. Branch on `text.startsWith(code + \": \")`, or parse it with `parseDomainErrorText(text)` from `@ggui-ai/protocol`. The numeric `-32xxx` table above covers only pre-execution protocol errors.\n\n### Retry with exponential backoff (raw `@modelcontextprotocol/sdk`)\n\nThe SDK throws on HTTP failures and on JSON-RPC errors alike; you classify by `error.code` (JSON-RPC) or by reading the HTTP status off the underlying response. The pattern below works for both layers.\n\n```typescript\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\n\nconst client = new Client({ name: \"my-agent\", version: \"1.0.0\" });\nconst transport = new StreamableHTTPClientTransport(new URL(\"http://127.0.0.1:6781/mcp\"), {\n  requestInit: { headers: { Authorization: \"Bearer dev\" } },\n});\nawait client.connect(transport);\n\nasync function callWithRetry<T>(\n  name: string,\n  args: Record<string, unknown>,\n  maxRetries = 3\n): Promise<T> {\n  for (let attempt = 0; attempt <= maxRetries; attempt++) {\n    try {\n      const result = await client.callTool({ name, arguments: args });\n      if (result.isError) {\n        // Tool-level failure — payload is in result.content.\n        throw new Error(`Tool ${name} returned isError: ${JSON.stringify(result.content)}`);\n      }\n      return result.structuredContent as T;\n    } catch (error) {\n      if (attempt === maxRetries) throw error;\n\n      // JSON-RPC error: error.code is a -32xxx number.\n      const code = (error as { code?: number }).code;\n\n      // Permanent — surface immediately.\n      if (code === -32007 || code === -32003) throw error; // auth / app config\n      if (code === -32600 || code === -32601 || code === -32602) throw error; // bad request\n\n      // Render expired — caller replays at the render layer (see below).\n      if (code === -32002) throw error;\n\n      // Rate-limited (HTTP 429) — honor Retry-After if the SDK surfaces it.\n      const retryAfter = (error as { retryAfter?: number }).retryAfter;\n      if (retryAfter != null) {\n        await sleep(retryAfter * 1000);\n        continue;\n      }\n\n      // Transient (5xx, network, -32603) — exponential backoff.\n      await sleep(Math.min(1000 * 2 ** attempt, 10_000));\n    }\n  }\n  throw new Error(\"unreachable\");\n}\n\nconst sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));\n```\n\n:::tip[Claude Agent SDK callers]\nWith `@anthropic-ai/claude-agent-sdk`, MCP failures surface inside the `SDKMessage` stream — tool results carry `is_error: true` and the failure detail lands as the tool-result content block. The agent loop sees them just like any other tool result and can decide whether to retry, ask the user, or abort. The same `-32xxx` code semantics apply.\n:::\n\n### Recover from an expired render (`-32002`)\n\n`-32002 Session Not Found` is the protocol-level canonical code for \"the session is gone\". On the OSS server, though, expiry never surfaces as a numeric JSON-RPC error from the `ggui_*` tools — the call succeeds at the JSON-RPC layer and returns `isError: true` with the failure detail in the result content:\n\n- **`handshake_not_found`** (from `ggui_render`) — handshake records are single-use and TTL'd (10 minutes); the supplied `handshakeId` was unknown, already consumed, or expired, and the result's text leads with the code: `handshake_not_found: handshakeId \"<id>\" not found. …`. (A render _consumes_ its handshake — handshakes aren't bound to renders.) A _missing_ `handshakeId` never gets this far: it is refused at input validation with `-32602`, and the message says `handshakeId is REQUIRED`.\n- **`session_not_found`** (from `ggui_consume` / `ggui_update` / `ggui_amend` / `ggui_emit` / `ggui_get_session`) — the `sessionId` was never minted, expired via TTL, was closed, or belongs to another app; the result's text leads with the code: `session_not_found: GguiSession \"<id>\" not found. …`.\n\nRecovery is the same in every case: re-run `ggui_handshake` → `ggui_render`, which mints a fresh `sessionId`. (Other `isError` results from `ggui_render` — contract violations, schema mismatches — leave the handshake **alive**: fix the arguments and retry on the _same_ `handshakeId`.)\n\n```typescript\nfunction errorText(result: { isError?: boolean; content?: unknown }): string {\n  const blocks = (result.content ?? []) as Array<{ type: string; text?: string }>;\n  return blocks\n    .filter((b) => b.type === \"text\")\n    .map((b) => b.text)\n    .join(\" \");\n}\n\nasync function resilientRender(intent: string, contract: object, props: Record<string, unknown>) {\n  const mintHandshake = async () => {\n    const hs = await client.callTool({\n      name: \"ggui_handshake\",\n      arguments: { intent, blueprintDraft: { contract } },\n    });\n    return (hs.structuredContent as { handshakeId: string }).handshakeId;\n  };\n\n  // Negotiate, then render. `ggui_render` takes { handshakeId, props } —\n  // accept the suggestion as-is (no `override`).\n  let result = await client.callTool({\n    name: \"ggui_render\",\n    arguments: { handshakeId: await mintHandshake(), props },\n  });\n\n  // Expired / already-consumed handshake → mint a fresh\n  // handshake → render pair (a fresh sessionId comes with it).\n  if (result.isError && errorText(result).startsWith(\"handshake_not_found: \")) {\n    result = await client.callTool({\n      name: \"ggui_render\",\n      arguments: { handshakeId: await mintHandshake(), props },\n    });\n  }\n  return result;\n}\n```\n\n## Layer 3 — typed failures on the live channel\n\nThe transport- and JSON-RPC-level errors above fire when the **request** fails. A separate layer surfaces **after** a successful render: when the renderer dispatches an action that fails validation (undeclared action name, payload rejected by the declared `actionSpec[name].schema`), the server answers with a typed `error` frame carrying `code: 'CONTRACT_VIOLATION'` — and nothing lands on the consume buffer. A dispatch sent through the relay (`ggui_runtime_submit_action`) is refused the same way, as a normal tool result `{ ok: false, code: 'CONTRACT_VIOLATION', message, violations }` in which each violation carries at least `field` and `message`. It is not an `isError` result, and the gesture never reaches `ggui_consume`.\n\nThese ride the live-channel WebSocket alongside the renderer — not the agent-side MCP poll — so there is no JSON-RPC error to catch on the agent. The renderer observes them and surfaces an error activity row.\n\nEarlier protocol drafts reserved a `_ggui:contract-error` channel carrying a `ContractErrorPayload` envelope, but it never gained a first-party emitter and was removed in `draft-2026-06-11` — the channel, payload shape, validator, and code union are all deleted. Contract failures now surface on the call that caused them: inbound action violations answer with a `CONTRACT_VIOLATION` `error` frame on the live channel, and nothing reaches the consume buffer; `ggui_render` / `ggui_emit` validation failures reject the agent's own tool call; push-time schema mismatches reject with `SCHEMA_MISMATCH_ERROR`. The reserved `_ggui:` namespace itself survives — the only first-party reserved channels today are `_ggui:lifecycle` and `_ggui:preview` — and your `streamSpec` MUST NOT declare reserved names.\n\n### Translate errors to user-facing messages\n\nKeep user-visible copy at one layer; never leak stack traces or JSON-RPC codes to end users.\n\n```typescript\nfunction getUserMessage(error: unknown): string {\n  const code = (error as { code?: number }).code;\n  const status = (error as { status?: number }).status;\n\n  if (status === 401 || code === -32007) return \"Authentication failed — contact support.\";\n  if (status === 429) return \"Too many requests — please slow down.\";\n  if (code === -32002) return \"Your session expired.\";\n  if (status && status >= 500) return \"The server is temporarily unavailable.\";\n  return \"Something went wrong.\";\n}\n\n// Neither a generation failure nor a refusal is a thrown error — both\n// arrive as an isError tool RESULT, and they are DIFFERENT outcomes.\n// Branch on `outcome` first: `failed` classifies on `error.code`,\n// `refused` on `refusal.code` and carries its own recovery step.\nimport {\n  isFailedRenderOutput,\n  isRefusedRenderOutput,\n  type GguiRenderOutput,\n} from \"@ggui-ai/protocol\";\n\nfunction getRenderProblemMessage(result: {\n  isError?: boolean;\n  structuredContent?: GguiRenderOutput;\n}): string | null {\n  if (result.isError !== true) return null;\n  const sc = result.structuredContent;\n  if (sc === undefined) return \"Something went wrong generating the UI.\";\n\n  // Declined BEFORE any work: nothing was committed and the handshake\n  // is still valid. `fix` is the one recovery step — show it rather\n  // than a generic message, and only retry the same handshakeId when\n  // the registry says the caller is the party who can act.\n  if (isRefusedRenderOutput(sc)) {\n    return sc.refusal.retry === \"never\"\n      ? sc.refusal.message\n      : `${sc.refusal.message} ${sc.refusal.fix}`;\n  }\n\n  // Generation RAN and produced nothing. The handshake is consumed, so\n  // recovery starts at a fresh ggui_handshake.\n  if (isFailedRenderOutput(sc)) {\n    switch (sc.error.code) {\n      case \"PRODUCTION_FAILED\":\n        return \"We couldn't generate that UI — try a simpler request.\";\n      case \"NO_CREDENTIALS\":\n      case \"NO_PLATFORM_KEY\":\n        return \"No LLM credentials are configured for this app.\";\n      case \"VALIDATION_ERROR\":\n        return \"This app's key configuration needs attention.\";\n      default:\n        return \"Something went wrong generating the UI.\";\n    }\n  }\n\n  return \"Something went wrong generating the UI.\";\n}\n```\n\n---\n\n## Renderer-side: faults stay inside the iframe\n\nThe canonical web host mounts each render in a **sandboxed iframe** via `<AppRenderer>` (from `@mcp-ui/client`, driven by `useMcpAppsChat` — see [React host helpers](/host-helpers/react/)). That sandbox is the fault boundary: an error thrown by LLM-generated component code is contained to its own iframe and **cannot crash your host React tree**. You don't wrap renders in a host-side error boundary — the origin isolation does that structurally.\n\nTwo host-observable failure surfaces matter.\n\n### Transport faults — `<AppRenderer onError>`\n\n`<AppRenderer>`'s own `onError` fires for iframe/transport-level failures (the sandbox bundle failed to load, the runtime failed to boot). It receives a plain `Error`. Log it or show a placeholder in the frame:\n\n```tsx\n<AppRenderer\n  toolName=\"ggui_render\"\n  sandbox={sandbox}\n  html={inlinedHtml}\n  onReadResource={onReadResource}\n  onCallTool={onCallTool}\n  onMessage={handleAppMessage}\n  onError={(err) => console.warn(\"[render] AppRenderer error\", err)}\n/>\n```\n\n(This is exactly what the [`ggui-basic-web`](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) sample does.)\n\n### Structured failures — the `ggui:observe` channel\n\nAfter a successful mount, GGUI's iframe-runtime emits a typed `ObservabilityEvent` to the parent on a dedicated postMessage channel — `{ type: \"ggui:observe\", event }`. This is where runtime health signals surface, so a host can show an activity row without parsing wire frames. The union is **extensibly-closed** — match the kinds you know, treat the unknown tail as generic:\n\n| `event.kind`              | When                                                                         |\n| ------------------------- | ---------------------------------------------------------------------------- |\n| `subscribe-failed`        | A non-fatal subscribe failure the reconnect ladder is handling.              |\n| `schema-version-mismatch` | The protocol-version handshake rejected the connection.                      |\n| `channel-transport-*`     | A `streamSpec` channel picked, fell back to, or resubscribed on a transport. |\n\nThe `ObservabilityEvent` union + its member types are re-exported from `@ggui-ai/mcp-apps-react` (`import type { ObservabilityEvent } from \"@ggui-ai/mcp-apps-react\"`). The `{ type: \"ggui:observe\", event }` envelope is specified in [Bootstrap handshake](/protocol/bootstrap-handshake/).\n\n:::note[Action-validation rejections never reach the agent]\nAn action that fails validation against the render's `actionSpec` is rejected at receipt with a typed `error` frame on the renderer's channel, **not** the agent's MCP tool loop — there is no JSON-RPC error for the agent to catch, and the event never lands on the consume buffer. The host observes it; the agent simply never sees the event.\n:::\n\n:::tip[Self-repair is server-side]\nWhen generated component code fails, regeneration happens inside the server's **generation pipeline**. On self-hosted `ggui serve` that pipeline runs locally with your provider key (set `ANTHROPIC_API_KEY` or visit `/settings`); the hosted backend runs the same pipeline managed. There is no host-side repair component to mount on the `<AppRenderer>` path — the iframe re-mounts with corrected HTML on the next `resources/read`.\n:::\n\n---\n\n## See also\n\n- [`/api/mcp-protocol/#error-codes`](/api/mcp-protocol/#error-codes) — full JSON-RPC error-code table; the live channel's `CONTRACT_VIOLATION` frame code is a string, not a number\n- [`/hosted/rate-limits/`](/hosted/rate-limits/) — HTTP `429` response shape, `Retry-After` semantics, raw-HTTP backoff recipe\n- [`@ggui-ai/mcp-apps-react` host helpers](/host-helpers/react/) — `<AppRenderer>` + `useMcpAppsChat`, the web render host\n- [Bootstrap handshake](/protocol/bootstrap-handshake/) — the `ggui:observe` channel + `ObservabilityEvent` catalog\n- [Troubleshooting](/troubleshooting/) — common errors and their root causes"},{"path":"/cookbook/feedback-form.md","title":"Feedback Form","description":"Configure an agent to render a feedback form via GGUI's MCP server, then read the typed result — the smallest end-to-end GGUI pattern.","body":"---\ntitle: Feedback Form\ndescription: Configure an agent to render a feedback form via GGUI's MCP server, then read the typed result — the smallest end-to-end GGUI pattern.\n---\n\n:::note[Self-hosted by default]\nThis recipe wires the Claude Agent SDK to a local `ggui serve` endpoint — `http://127.0.0.1:6781/mcp` with `Authorization: Bearer dev`, which pairs with `ggui serve --dev-allow-all`. See the [self-hosted quickstart](/quickstart/self-hosted/) for the local-first walkthrough. The single-tenant server scopes everything to one app, so the bearer alone identifies it.\n:::\n\n:::note[Hosted]\nOn the hosted platform, point the same `mcpServers` entry at the universal endpoint `https://mcp.ggui.ai` or your per-app endpoint `https://mcp.ggui.ai/apps/<appId>` — note there is **no `/mcp` suffix** on the hosted URLs (that suffix is local-`ggui serve`-only) — with a `ggui_user_*` [connector key](/hosted/connector-keys/) in the `Authorization` header. The appId rides in the per-app URL path, not a header. The wire flow is identical.\n:::\n\nThe smallest useful GGUI interaction: an agent renders a form, the user submits, the agent reads the typed data. Following the **Zero Agent Code** principle, you don't hand-call `handshake` / `render` / `consume` — you configure the agent host with GGUI's MCP server and the LLM autonomously drives the loop from a user prompt.\n\nTwo flavors below — the canonical LLM-driven shape via the Claude Agent SDK, and a manual orchestration variant using `@modelcontextprotocol/sdk` when you need imperative control.\n\n## Canonical: Claude Agent SDK + GGUI MCP\n\nThe developer's job: define the typed contract, configure the host, write a user-facing prompt. The LLM autonomously calls `ggui_handshake` → `ggui_render` → `ggui_consume` and surfaces the typed payload.\n\n```typescript\nimport { query } from \"@anthropic-ai/claude-agent-sdk\";\nimport { defineContract } from \"@ggui-ai/protocol\";\n\n// Typed contract — `actionData` for `submit` narrows to {rating, comments}.\n// The contract still lives in code: it's how you document the shape\n// the agent should negotiate during `ggui_handshake`.\n// NOTE: the intent string is NOT a contract field — it travels separately\n// as the flat `intent` argument of `ggui_handshake` (see the manual\n// orchestration variant below).\nconst feedbackContract = defineContract({\n  propsSpec: {\n    properties: {\n      userName: { schema: { type: \"string\" } },\n      product: { schema: { type: \"string\" } },\n    },\n  },\n  actionSpec: {\n    submit: {\n      label: \"Submit feedback\",\n      schema: {\n        type: \"object\",\n        properties: {\n          rating: { type: \"number\", minimum: 1, maximum: 5 },\n          comments: { type: \"string\" },\n        },\n        required: [\"rating\", \"comments\"],\n      },\n    },\n  },\n} as const);\n\nasync function collectFeedback(userName: string, product: string) {\n  const result = query({\n    prompt: `Collect product feedback from ${userName} about ${product}.\n             Render a feedback form with a 1–5 star rating, a comments text\n             area, and a submit button. Greet the user by name. Wait for\n             their submission, then report the rating and comments back\n             to me as JSON: {\"rating\": number, \"comments\": string}.`,\n    options: {\n      mcpServers: {\n        ggui: {\n          type: \"http\",\n          url: \"http://127.0.0.1:6781/mcp\", // ggui serve --dev-allow-all\n          headers: {\n            Authorization: \"Bearer dev\",\n          },\n        },\n      },\n      allowedTools: [\n        \"mcp__ggui__ggui_handshake\",\n        \"mcp__ggui__ggui_render\",\n        \"mcp__ggui__ggui_consume\",\n      ],\n    },\n  });\n\n  // The LLM drives ggui_handshake → ggui_render → ggui_consume on its own.\n  // The render surfaces as an MCP-Apps resource the host mounts;\n  // the typed payload comes back in the final message.\n  for await (const message of result) {\n    if (message.type === \"assistant\") {\n      console.log(message.message.content);\n    }\n  }\n}\n```\n\n:::tip[Typed payloads]\n`feedbackContract` is wrapped in `defineContract({...} as const)` so `InferActionPayload<typeof feedbackContract, 'submit'>` resolves to `{rating: number; comments: string}` — the same shape `actionSpec.submit.schema` validates server-side when `ggui_consume` returns. The contract is documentation for the developer and a schema for the runtime; the agent learns the shape from the MCP tool descriptions, not from importing this constant. See [`ggui_consume`](/api/mcp-protocol/#ggui_consume).\n:::\n\nFor a complete runnable example (including streaming partial events, multi-turn refinement, and the host's MCP wiring), see [Examples → Claude Agent](/examples/claude-agent/).\n\n## Manual orchestration: `@modelcontextprotocol/sdk`\n\nUse this shape when you need imperative control — e.g. you're building a non-LLM workflow, testing the protocol directly, or reacting to every action the user fires (not just the terminal submit). This calls GGUI's MCP tools directly via the official MCP SDK; no LLM in the loop.\n\n```typescript\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport { isRenderedOutput, type GguiRenderOutput } from \"@ggui-ai/protocol\";\n\nconst transport = new StreamableHTTPClientTransport(new URL(\"http://127.0.0.1:6781/mcp\"), {\n  requestInit: {\n    headers: { Authorization: \"Bearer dev\" },\n  },\n});\n\nconst client = new Client({ name: \"feedback-script\", version: \"1.0.0\" });\nawait client.connect(transport);\n\nasync function collectFeedbackLive(userName: string, product: string) {\n  // 1. Handshake — negotiate the contract before rendering.\n  //    Pre-launch, `ggui_render` is handshake-first only.\n  const hsResp = await client.callTool({\n    name: \"ggui_handshake\",\n    arguments: {\n      intent: \"Collect product feedback (live)\",\n      blueprintDraft: {\n        contract: feedbackContract,\n        variance: {\n          seedPrompt: `Show ${userName} a product feedback form for ${product}.`,\n        },\n      },\n    },\n  });\n  const { handshakeId } = JSON.parse((hsResp.content[0] as { type: \"text\"; text: string }).text);\n\n  // 2. Render — accept the handshake's suggestion (omit `override`).\n  //    `ggui_render` mints the `sessionId`.\n  const renderResp = await client.callTool({\n    name: \"ggui_render\",\n    arguments: {\n      handshakeId,\n      props: { userName, product },\n    },\n  });\n  const render: GguiRenderOutput = JSON.parse(\n    (renderResp.content[0] as { type: \"text\"; text: string }).text\n  );\n  // Read `outcome` before the identity fields. Only `rendered` and\n  // `failed` carry a sessionId; a `refused` result carries neither it\n  // nor a mount URI, because the deployment declined before doing any\n  // work (the handshake is still valid — see the error-handling guide).\n  if (!isRenderedOutput(render)) {\n    throw new Error(`ggui_render returned outcome \"${render.outcome}\"`);\n  }\n  const { sessionId, resourceUri } = render;\n  console.log(`Render ${sessionId} ready (${resourceUri}) — a host mounts it.`);\n\n  // 3. Consume — long-poll until the user submits.\n  while (true) {\n    const consumeResp = await client.callTool({\n      name: \"ggui_consume\",\n      arguments: { sessionId, timeout: 25 },\n    });\n    const { events, status } = JSON.parse(\n      (consumeResp.content[0] as { type: \"text\"; text: string }).text\n    );\n\n    for (const entry of events) {\n      // Every consume entry has `type: 'action'`; the contract's\n      // `actionSpec` key fires on `entry.intent`.\n      if (entry.intent === \"submit\") {\n        console.log(\"Submitted:\", entry.actionData);\n        return entry.actionData;\n      }\n      console.log(`Action ${entry.intent}:`, entry.actionData);\n    }\n\n    if (status !== \"active\") break; // 'expired' — render TTL elapsed\n  }\n}\n```\n\n:::caution[No `sleep()` between polls]\nPass a `timeout` to `ggui_consume` (recommended: `15` or `25` seconds for chat agents — `25` is the maximum; the server rejects anything above 25 with `INVALID_PARAMS`) so the server long-polls. A busy `while` loop without a timeout — or with a hand-rolled `setTimeout` between calls — burns budget and adds latency.\n:::\n\n## What the user sees\n\nThe agent renders the form into whatever MCP-Apps host the user is in — inline in claude.ai / Claude Desktop, or in your own app via `<AppRenderer>`. The user fills it in and submits; the agent reads the typed result off `ggui_consume`:\n\n```\nRating: 4/5 — Great product, would love dark-mode support!\n```\n\nThere is no URL to hand the user — the render is an MCP-Apps resource the host mounts. (For local development without an MCP-Apps host, the operator console bundled with `ggui serve` can display the render for debugging.)\n\n## Related\n\n- [Examples → Claude Agent](/examples/claude-agent/) — full runnable Claude Agent SDK + GGUI MCP example\n- [MCP protocol reference](/api/mcp-protocol/) — wire-level `ggui_handshake` / `ggui_render` / `ggui_consume` shapes\n- [Event system](/architecture/event-system/) — `actionSpec`-driven flow and the `ConsumeEventEntry` shape\n- [Multi-step wizard](/cookbook/multi-step-wizard/) — chain forms across successive renders\n- [Glossary](/glossary/) — `render`, `contract`, `envelope`, `blueprint`"},{"path":"/cookbook/multi-step-wizard.md","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.","body":"---\ntitle: Multi-Step Wizard\ndescription: Let the LLM sequence a back-navigable wizard by minting a fresh render per step and prefilling on back-navigation.\n---\n\n:::note[Endpoint defaults]\nThis recipe points the agent host at a GGUI MCP URL via `GGUI_MCP_URL` / `GGUI_MCP_BEARER`. The documented default is self-hosted: `GGUI_MCP_URL=http://127.0.0.1:6781/mcp` with `GGUI_MCP_BEARER=dev` (pairs with `ggui serve --dev-allow-all`). On the hosted platform, 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_user_*` [connector key](/hosted/connector-keys/). The contract shape and prompt do not change. See the [self-hosted quickstart](/quickstart/self-hosted/) for the local-first walkthrough.\n:::\n\nWizards 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`.\n\n## Pattern\n\n1. 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.\n2. You describe the wizard once in the user prompt and define one typed `defineContract` per step.\n3. The LLM autonomously sequences: `ggui_handshake` → `ggui_render` → `ggui_consume` → react on `intent` → next step's `ggui_handshake` → `ggui_render` → ... .\n4. 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.\n\n:::note[Consume vs WebSocket envelopes]\n`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.\n:::\n\n## LLM-driven flow (canonical — Claude Agent SDK)\n\nConfigure 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.\n\n```typescript\nimport { query } from \"@anthropic-ai/claude-agent-sdk\";\nimport { GGUI_AGENT_SYSTEM_PROMPT, defineContract } from \"@ggui-ai/protocol\";\n\n// One contract per step. `defineContract({...} as const)` infers TS payload\n// types from the JSON Schemas — no parallel interface definitions.\n// A DataContract declares data flow only (propsSpec / actionSpec /\n// streamSpec / contextSpec) — there are no `name` or `layout` fields.\n// The step header (\"Step 1 of 3 — Personal Information\") travels as the\n// handshake `intent` (and, for finer aim, `blueprintDraft.variance.seedPrompt`).\nconst personalInfoContract = defineContract({\n  actionSpec: {\n    submit: {\n      label: \"Next\",\n      schema: {\n        type: \"object\",\n        required: [\"fullName\", \"email\", \"phone\"],\n        properties: {\n          fullName: { type: \"string\" },\n          email: { type: \"string\", format: \"email\" },\n          phone: { type: \"string\" },\n        },\n      },\n    },\n  },\n} as const);\n\n// ... companyInfoContract + reviewContract defined similarly (also expose a\n// \"back\" actionSpec entry on step 2 and 3).\n\nfor await (const event of query({\n  prompt: `Walk me through a 3-step onboarding wizard.\n\nStep 1 (Personal Info): use this contract verbatim\n${JSON.stringify(personalInfoContract)}\n\nStep 2 (Company Info, with Back): use this contract verbatim\n${JSON.stringify(companyInfoContract)}\n\nStep 3 (Review & Confirm): use this contract verbatim\n${JSON.stringify(reviewContract)}\nRender step 3 with props.summary = {personal, company} so the summary can render.\n\nNavigation rules:\n- After each ggui_render, call ggui_consume and inspect events[].intent.\n- intent === \"submit\": advance to the next step's handshake + render, threading\n  prior data via the render's props.\n- intent === \"back\": re-handshake the prior step's contract with\n  props.prefill = priorActionData so values are restored.\n- After step 3 submit, simply stop — there is no close ceremony.`,\n  options: {\n    model: \"claude-haiku-5-5\",\n    systemPrompt: GGUI_AGENT_SYSTEM_PROMPT,\n    mcpServers: {\n      ggui: {\n        type: \"http\",\n        url: process.env.GGUI_MCP_URL!, // e.g. http://127.0.0.1:6781/mcp\n        headers: { Authorization: `Bearer ${process.env.GGUI_MCP_BEARER!}` },\n      },\n    },\n    allowedTools: [\n      \"mcp__ggui__ggui_handshake\",\n      \"mcp__ggui__ggui_render\",\n      \"mcp__ggui__ggui_consume\",\n    ],\n    tools: [],\n    strictMcpConfig: true,\n  },\n})) {\n  // Inspect events for logging; the LLM drives each render autonomously.\n  if (event.type === \"assistant\") console.log(event.message);\n}\n```\n\n:::tip[Typed payloads via `defineContract`]\n`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).\n:::\n\n## Manual orchestration (raw MCP, no Claude in the loop)\n\nWhen you need explicit control over each step — testing, deterministic playback, non-LLM driver — open a raw MCP session and call `ggui_*` tools yourself.\n\n```typescript\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport { defineContract, isRenderedOutput, type GguiRenderOutput } from \"@ggui-ai/protocol\";\n\nconst transport = new StreamableHTTPClientTransport(new URL(process.env.GGUI_MCP_URL!), {\n  requestInit: { headers: { Authorization: `Bearer ${process.env.GGUI_MCP_BEARER!}` } },\n});\nconst mcp = new Client({ name: \"wizard-driver\", version: \"1.0.0\" });\nawait mcp.connect(transport);\n\nasync function call<T>(name: string, args: unknown): Promise<T> {\n  const res = await mcp.callTool({ name, arguments: args as Record<string, unknown> });\n  return JSON.parse((res.content[0] as { text: string }).text) as T;\n}\n\nasync function step<TIntent extends string>(\n  contract: unknown,\n  intent: string,\n  prefill?: Record<string, unknown>\n): Promise<{ intent: TIntent; actionData: unknown; sessionId: string }> {\n  // ggui_handshake({ intent, blueprintDraft }) → { handshakeId, action, suggestion }\n  const hs = await call<{ handshakeId: string }>(\"ggui_handshake\", {\n    intent,\n    blueprintDraft: { contract },\n  });\n  // ggui_render({ handshakeId, props }) — props is REQUIRED (pass {} when none).\n  // The response leads with `outcome`; only `rendered` and `failed`\n  // carry a sessionId. A `refused` result carries none — the deployment\n  // declined before doing any work, and the handshake stays valid.\n  const render = await call<GguiRenderOutput>(\"ggui_render\", {\n    handshakeId: hs.handshakeId,\n    props: prefill ? { prefill } : {},\n  });\n  if (!isRenderedOutput(render)) {\n    throw new Error(`ggui_render returned outcome \"${render.outcome}\"`);\n  }\n  const { sessionId } = render;\n  // Long-poll ggui_consume until the user submits or goes back.\n  for (;;) {\n    const { events, status } = await call<{\n      events: Array<{ intent: string; actionData: unknown }>;\n      status: \"active\" | \"expired\";\n    }>(\"ggui_consume\", { sessionId, timeout: 25 });\n    // `intent` is the actionSpec key the iframe dispatched against — \"back\"\n    // is its own entry, not a sub-field of \"submit\".\n    const entry = events.find((e) => e.intent === \"back\" || e.intent === \"submit\");\n    if (entry) return { ...(entry as { intent: TIntent; actionData: unknown }), sessionId };\n    if (status === \"expired\") throw new Error(\"render expired before a terminal action\");\n  }\n}\n\n// Step 1\nconst personal = await step<\"submit\">(\n  personalInfoContract,\n  \"Onboarding step 1 of 3 — personal info\"\n);\n\n// Step 2 with back handling\nlet company: { intent: \"submit\"; actionData: unknown } | null = null;\nwhile (!company) {\n  const r = await step<\"submit\" | \"back\">(\n    companyInfoContract,\n    \"Onboarding step 2 of 3 — company info\"\n  );\n  if (r.intent === \"back\") {\n    // Re-render step 1 with the prior actionData as prefill — a fresh sessionId\n    // is minted; the prior render is simply left behind — no close call.\n    await step<\"submit\">(\n      personalInfoContract,\n      \"Onboarding step 1 of 3 — personal info (revisit)\",\n      personal.actionData as Record<string, unknown>\n    );\n    continue;\n  }\n  company = r as { intent: \"submit\"; actionData: unknown };\n}\n\n// No explicit close — the render is left behind after step 3 submit.\n```\n\n## Step lineage\n\n```\nStep 1 render:        sessionId=r_p1   (personal info)\nStep 2 render:        sessionId=r_c1   (company info)        ← user sees Company\nUser clicks Back:     -- no pop --     (r_c1 left behind)\nStep 1 revisit:       sessionId=r_p2   (prefilled with prior personal data)\nStep 2 revisit:       sessionId=r_c2\nStep 3:               sessionId=r_r1   (review & confirm)\n```\n\nEach 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.\n\n## Inspecting a session's state\n\n`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.\n\n```typescript\nconst state = await call<{\n  id: string;\n  eventSequence: number;\n  lastActivityAt: number;\n  expiresAt: number;\n}>(\"ggui_get_session\", { sessionId: company!.sessionId });\nconsole.log(\n  `Render ${state.id}: ${state.eventSequence} events, alive until ${new Date(state.expiresAt).toISOString()}`\n);\n```\n\n## See also\n\n- [Claude Agent example](/examples/claude-agent/) — end-to-end LLM-driven loop wired to a GGUI MCP server\n- [MCP protocol reference](/api/mcp-protocol/) — full `ggui_*` tool signatures\n- [GguiSession](/glossary/#gguisession-a-render) — the unit minted by each step\n- [Feedback Form](/cookbook/feedback-form/) — single-step variant of the same pattern"},{"path":"/cookbook/real-time-dashboard.md","title":"Real-Time Dashboard","description":"Stream live data into a generated UI by declaring a streamSpec channel on the contract and pushing updates with ggui_emit. The iframe owns the live channel; your host app just mounts the render.","body":"---\ntitle: Real-Time Dashboard\ndescription: Stream live data into a generated UI by declaring a streamSpec channel on the contract and pushing updates with ggui_emit. The iframe owns the live channel; your host app just mounts the render.\n---\n\nLive data in GGUI is a **contract concern**, not a host-app concern. You don't open a WebSocket in your React app and feed events into the render. Instead:\n\n1. The agent declares a **`streamSpec`** channel on the contract — a typed, named live channel.\n2. The agent renders the contract once, then pushes updates with **`ggui_emit`**.\n3. The **generated component inside the iframe** subscribes to that channel and repaints as deliveries arrive. The iframe-runtime owns the channel transport (it picks WebSocket or polling per channel).\n\nYour host app does nothing dashboard-specific: it mounts the render with `<AppRenderer>` (via `useMcpAppsChat`) like any other GGUI UI. The live updates flow **inside** the sandboxed iframe — they never pass through your host code.\n\n:::note[Endpoint defaults]\nSnippets default to **self-hosted GGUI** — `http://127.0.0.1:6781/mcp` with `Authorization: Bearer dev`, pairing with `ggui serve --dev-allow-all` (or wherever `ggui serve` is bound). On the hosted platform, point the transport at your per-app endpoint `https://mcp.ggui.ai/apps/<appId>` — no `/mcp` suffix. Only the URL + auth header change.\n:::\n\n## 1. Declare a `streamSpec` channel\n\nA `streamSpec` is a flat `Record<channelName, StreamChannelEntry>` on the contract. Each channel declares a JSON Schema its payloads must satisfy, plus optional accumulation + replay behavior:\n\n```typescript\nconst contract = {\n  // What the component shows initially (validated like any props).\n  propsSpec: {\n    properties: {\n      title: { schema: { type: \"string\" }, required: true },\n    },\n  },\n  // Live channels. Payloads on `metrics` must match its schema.\n  streamSpec: {\n    metrics: {\n      description: \"Live throughput + error-rate samples for the dashboard.\",\n      schema: {\n        type: \"object\",\n        properties: {\n          ts: { type: \"string\" },\n          rps: { type: \"number\" },\n          errorRate: { type: \"number\" },\n        },\n        required: [\"ts\", \"rps\"],\n      },\n      mode: \"append\", // accumulate each delivery into a series\n      replay: \"all\", // a late-subscribing iframe gets the full backlog\n      complete: true, // allow a terminal ggui_emit({ complete: true })\n    },\n  },\n};\n```\n\n| Channel field | Meaning                                                                                                                                                   |\n| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `schema`      | **Required.** JSON Schema every payload on this channel must satisfy. `ggui_emit` validates against it.                                                   |\n| `mode`        | `\"append\"` accumulates deliveries into a series (a feed / chart); `\"replace\"` keeps only the latest (a single live value). Optional (default `\"append\"`). |\n| `replay`      | What a freshly-mounted iframe receives: `\"all\"` (full backlog), `\"latest\"` (last delivery only), `\"none\"`. Optional (default `\"none\"`).                   |\n| `complete`    | Declare `complete: true` to allow a terminal `ggui_emit({ complete: true })` that closes the channel; undeclared channels reject it. Optional.            |\n| `description` | Natural-language hint that steers how the generator wires the component to the channel. Optional but recommended.                                         |\n| `source`      | `{ tool, args? }` — declare a **pull** channel the runtime polls instead of you pushing. See [Pull channels](#pull-channels-source).                      |\n\nThe example's `replay: \"all\"` and `complete: true` are both explicit opt-ins on top of the defaults.\n\n## 2. Render, then emit\n\nNegotiate + materialize the contract through the normal `ggui_handshake` → `ggui_render` flow, then push deliveries with `ggui_emit`. Payloads validate against `streamSpec[channel].schema`; a final `complete: true` closes the channel (allowed because the declaration above opted in with `complete: true`).\n\n```typescript\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport { isRenderedOutput, type GguiRenderOutput } from \"@ggui-ai/protocol\";\n\nconst client = new Client({ name: \"dashboard-agent\", version: \"1.0.0\" });\nawait client.connect(\n  new StreamableHTTPClientTransport(new URL(\"http://127.0.0.1:6781/mcp\"), {\n    requestInit: { headers: { Authorization: \"Bearer dev\" } },\n  })\n);\n\n// Negotiate a blueprint for the dashboard contract.\nconst handshake = await client.callTool({\n  name: \"ggui_handshake\",\n  arguments: {\n    intent: \"Live ops dashboard — throughput + error rate\",\n    blueprintDraft: { contract },\n  },\n});\nconst { handshakeId } = handshake.structuredContent as { handshakeId: string };\n\n// Materialize it. `props` is REQUIRED — pass the initial values.\nconst render = await client.callTool({\n  name: \"ggui_render\",\n  arguments: { handshakeId, props: { title: \"Ops — us-east-1\" } },\n});\nconst rendered = render.structuredContent as GguiRenderOutput;\n// Read `outcome` before the identity fields: only `rendered` and\n// `failed` carry a sessionId, and a `refused` result carries none at\n// all — the deployment declined before doing any work.\nif (!isRenderedOutput(rendered)) {\n  throw new Error(`ggui_render returned outcome \"${rendered.outcome}\"`);\n}\nconst { sessionId } = rendered;\n\n// Stubs — swap in your real metric source.\nconst sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));\nconst sample = () => 100 + Math.random() * 50;\nconst sampleErr = () => Math.random() * 0.05;\n\n// Push live deliveries on the declared channel.\nfor (let i = 0; i < 100; i++) {\n  await client.callTool({\n    name: \"ggui_emit\",\n    arguments: {\n      sessionId,\n      channel: \"metrics\",\n      payload: { ts: new Date().toISOString(), rps: sample(), errorRate: sampleErr() },\n    },\n  });\n  await sleep(1000);\n}\n\n// Close the channel — subsequent emits on it reject.\nawait client.callTool({\n  name: \"ggui_emit\",\n  arguments: { sessionId, channel: \"metrics\", payload: {}, complete: true },\n});\n```\n\n:::tip[Claude Agent SDK callers]\nWith `@anthropic-ai/claude-agent-sdk` you don't call these tools by hand — the model does. `GGUI_AGENT_SYSTEM_PROMPT` teaches it the `handshake → render → emit` sequence; you describe the dashboard in the prompt (\"render a live ops dashboard and stream throughput every second\") and let the agent loop drive the emits. The wire contract is identical.\n:::\n\n## 3. Host side: just mount the render\n\nThere is no dashboard-specific host code. The render arrives as an MCP-Apps resource on the agent's tool result; your web app drives the conversation with `useMcpAppsChat` and mounts each render with `<AppRenderer>`. The generated component subscribes to the `metrics` channel itself, and the iframe-runtime delivers every `ggui_emit` into it — your host never sees the channel frames.\n\n```tsx\nimport { AppRenderer } from \"@mcp-ui/client\";\nimport { useMcpAppsChat } from \"@ggui-ai/mcp-apps-react/chat-helpers\";\n\nfunction Dashboard({ agentUrl }: { agentUrl: string }) {\n  const { sessions, handleAppMessage } = useMcpAppsChat({\n    chatEndpoint: `${agentUrl}/agent`,\n  });\n  // Mount the latest render with <AppRenderer>; live `metrics` deliveries\n  // repaint the chart INSIDE the iframe. See the React host-helpers page for the\n  // full sandbox + relay wiring.\n}\n```\n\nSee [React host helpers](/host-helpers/react/) for the complete `<AppRenderer>` host contract (sandbox-proxy origin + `onReadResource` / `onCallTool` relay + `onMessage`), and the [`ggui-basic-web`](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) sample for a runnable host.\n\n## Pull channels (`source`)\n\nThe example above is a **push** channel — the agent actively emits. For data the runtime should fetch on its own cadence, declare a `source` instead and skip `ggui_emit` entirely:\n\n```typescript\nstreamSpec: {\n  orders: {\n    description: \"Open orders, refreshed from the orders tool.\",\n    schema: { type: \"object\", properties: { id: { type: \"string\" }, total: { type: \"number\" } } },\n    source: { tool: \"list_open_orders\", args: { region: \"us-east-1\" } },\n  },\n}\n```\n\n`source.tool` MUST resolve to a tool the contract declares in its `agentCapabilities.tools` (a cross-reference the contract linter enforces). The iframe-runtime polls that tool and feeds results into the channel; the agent declares the channel and renders, but never pushes.\n\n## See also\n\n- [React host helpers](/host-helpers/react/) — `<AppRenderer>` + `useMcpAppsChat`, the host render-hosting surface\n- [MCP protocol reference](/api/mcp-protocol/) — `ggui_handshake`, `ggui_render`, `ggui_emit` request/response shapes\n- [Data vs Behavior](https://github.com/ggui-ai/ggui/blob/main/docs/principles/data-vs-behavior.md) — why live data is a contract field and rendering behavior is component code\n- [`ggui-basic-web` sample](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) — a runnable MCP-Apps host"},{"path":"/cookbook/testing.md","title":"Testing GGUI Integrations","description":"Mock the MCP transport for agent-side unit tests and assert the tool calls your agent makes, without a real LLM round-trip.","body":"---\ntitle: Testing GGUI Integrations\ndescription: Mock the MCP transport for agent-side unit tests and assert the tool calls your agent makes, without a real LLM round-trip.\n---\n\nGGUI's agent surface has a dedicated mock layer so you never need a live `mcp.ggui.ai` (or self-hosted `ggui serve`) connection in unit tests:\n\n- **Agent side** — mock the MCP transport (not a wrapper SDK) and assert the tool calls your agent makes.\n\nIntegration tests against a live endpoint belong in a separate tier — see [Self-Hosted Reference Deploys](/self-hosted/reference-deploys/) for spinning up a throwaway stack.\n\n## Agent side: mock the MCP transport\n\nGGUI is consumed over standard MCP, so the right place to draw the test boundary is at the **MCP transport**, not at a wrapper SDK. Stub the tool-call responses your agent expects and assert against the call log — your agent code stays unmodified between test and production.\n\n### With `@modelcontextprotocol/sdk` — stub `Client.callTool`\n\nIf your agent talks to GGUI through the canonical MCP `Client`, stub `callTool` and seed the responses keyed by tool name. A unit-test fixture only needs the fields your agent actually reads, so each builder returns a `Pick<>` of the real protocol type (`GguiHandshakeOutput`, `GguiRenderOutput`, `GguiConsumeOutput` from `@ggui-ai/protocol`) — keeping the field names and shapes wire-faithful without hand-rolling every required field of the live envelope. The one field a `Pick<>` of `GguiRenderOutput` must never drop is `outcome` — every render result carries it, and it is what a reader branches on before touching `sessionId` or `resourceUri`.\n\n```typescript\nimport { describe, it, expect, beforeEach, vi } from \"vitest\";\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { isRenderedOutput } from \"@ggui-ai/protocol\";\nimport type {\n  GguiHandshakeOutput,\n  GguiRenderOutput,\n  GguiConsumeOutput,\n  ConsumeEventEntry,\n} from \"@ggui-ai/protocol\";\n\nfunction makeMockMcpClient() {\n  const callLog: Array<{ name: string; arguments: unknown }> = [];\n  const responses = new Map<string, unknown>();\n  let lastSessionId: string | null = null;\n  const renderEvents = new Map<string, ConsumeEventEntry[]>();\n  const renderStatus = new Map<string, \"active\" | \"expired\">();\n\n  responses.set(\"ggui_handshake\", (): Pick<GguiHandshakeOutput, \"handshakeId\" | \"action\"> => ({\n    handshakeId: `h_${Date.now()}`,\n    action: \"create\",\n  }));\n\n  responses.set(\n    \"ggui_render\",\n    (): Pick<GguiRenderOutput, \"outcome\" | \"sessionId\" | \"resourceUri\" | \"action\"> => {\n      const sessionId = `rnd_${Date.now()}`;\n      lastSessionId = sessionId;\n      renderEvents.set(sessionId, []);\n      renderStatus.set(sessionId, \"active\");\n      return {\n        // REQUIRED on every render result — `rendered` | `failed` |\n        // `refused`. The identity fields below exist only because this\n        // mock reports `rendered`; a `refused` result carries nothing\n        // but `outcome` + `refusal`.\n        outcome: \"rendered\",\n        sessionId,\n        // Spec-canonical MCP-Apps entry point. There is NO clickable\n        // `url` on the wire — the host mounts the `ui://ggui/render/{id}`\n        // iframe resource. (A dead `url` had the model hallucinating\n        // links that resolve nowhere, so it was removed.)\n        resourceUri: `ui://ggui/render/${sessionId}`,\n        action: \"create\",\n      };\n    }\n  );\n\n  responses.set(\"ggui_consume\", (args: { sessionId: string }): GguiConsumeOutput => {\n    const events = renderEvents.get(args.sessionId) ?? [];\n    renderEvents.set(args.sessionId, []);\n    return {\n      events,\n      status: renderStatus.get(args.sessionId) ?? \"active\",\n    };\n  });\n\n  const client = {\n    callTool: vi.fn(async ({ name, arguments: args }) => {\n      callLog.push({ name, arguments: args });\n      const handler = responses.get(name);\n      if (!handler) {\n        // JSON-RPC -32601: Method not found — matches the live server.\n        throw new Error(`MCP tool not found: ${name}`);\n      }\n      const result = typeof handler === \"function\" ? handler(args) : handler;\n      // MCP wraps tool output in { content: [...], structuredContent: ... }.\n      return {\n        content: [{ type: \"text\", text: JSON.stringify(result) }],\n        structuredContent: result,\n      };\n    }),\n  } as unknown as Client;\n\n  return {\n    client,\n    callLog,\n    // Simulate a user gesture appearing on the consume pipe.\n    simulateSubmit(sessionId: string, data: ConsumeEventEntry[\"actionData\"]) {\n      const events = renderEvents.get(sessionId) ?? [];\n      events.push({\n        type: \"action\",\n        sessionId,\n        intent: \"submit\",\n        actionData: data,\n        uiContext: {},\n        actionId: \"mockactn\",\n        firedAt: new Date().toISOString(),\n      });\n      renderEvents.set(sessionId, events);\n    },\n    // Status semantics match the canonical protocol: `active` = more events\n    // may arrive; `expired` = TTL elapsed. Flip terminal state explicitly.\n    simulateExpire(sessionId: string) {\n      renderStatus.set(sessionId, \"expired\");\n    },\n  };\n}\n```\n\n### Driving the mock from a test\n\n```typescript\ndescribe(\"feedback agent\", () => {\n  let mock: ReturnType<typeof makeMockMcpClient>;\n\n  beforeEach(() => {\n    mock = makeMockMcpClient();\n  });\n\n  it(\"collects user feedback end-to-end\", async () => {\n    const handshake = await mock.client.callTool({\n      name: \"ggui_handshake\",\n      arguments: { intent: \"Collect feedback\" },\n    });\n    const { handshakeId } = handshake.structuredContent as GguiHandshakeOutput;\n\n    const render = await mock.client.callTool({\n      name: \"ggui_render\",\n      // `props` is REQUIRED on ggui_render — pass `{}` when the agreed\n      // contract declares no propsSpec.\n      arguments: { handshakeId, props: {} },\n    });\n    const result = render.structuredContent as GguiRenderOutput;\n    // Branch on `outcome` BEFORE touching `sessionId`: only `rendered`\n    // and `failed` carry the identity fields, and only `rendered`\n    // carries a mount URI. A `refused` result has neither — the\n    // deployment declined before doing any work, so nothing was\n    // committed. `isRenderedOutput` narrows the six identity fields to\n    // non-optional; there is no parallel union type to import.\n    if (!isRenderedOutput(result)) {\n      throw new Error(`expected a rendered result, got ${result.outcome}`);\n    }\n    const { sessionId, resourceUri } = result;\n    // The render's entry point is the spec-canonical MCP-Apps resource\n    // URI the host mounts — not a clickable link.\n    expect(resourceUri).toMatch(/^ui:\\/\\/ggui\\/render\\//);\n\n    // Pretend the user filled in the form.\n    mock.simulateSubmit(sessionId, { rating: 5, comments: \"Great!\" });\n\n    const consume = await mock.client.callTool({\n      name: \"ggui_consume\",\n      arguments: { sessionId },\n    });\n    const { events, status } = consume.structuredContent as GguiConsumeOutput;\n    expect(status).toBe(\"active\");\n    expect(events[0].actionData).toEqual({ rating: 5, comments: \"Great!\" });\n\n    // Assert the agent issued exactly the expected tool-call sequence.\n    expect(mock.callLog.map((c) => c.name)).toEqual([\n      \"ggui_handshake\",\n      \"ggui_render\",\n      \"ggui_consume\",\n    ]);\n  });\n});\n```\n\n:::caution[`actionData`, not `payload`]\n`ConsumeEventEntry` carries gesture data on `actionData` (the consume pipe is the agent-facing view of an `ActionEnvelope` from the live channel). `payload` is the live-channel wire field on the raw envelope. Asserting against `events[0].payload` will silently return `undefined`.\n:::\n\n### With Claude Agent SDK — stub `mcpServers`\n\nConsumers using `@anthropic-ai/claude-agent-sdk` typically pass an `mcpServers` config to `query()`. For unit tests, supply an in-memory server entry that returns canned tool responses instead of dialling `mcp.ggui.ai`. Use the SDK's `createSdkMcpServer` (or the SDK-specific test helper) and register tools that produce the same structured payloads shown above.\n\nThe principle is identical: stub at the MCP transport surface so your agent prompt, tool-loop logic, and consume-pipe handling exercise unchanged.\n\n### With raw HTTP — stub `fetch`\n\nIf your agent talks to a self-hosted `ggui serve` over plain HTTP (`http://127.0.0.1:6781/mcp`), `vi.spyOn(global, \"fetch\")` (or `msw`) is the right boundary. Assert request URL + JSON-RPC method, and return the matching `structuredContent` payload.\n\n### Asserting errors\n\nGGUI surfaces failures at protocol level — there are no SDK-specific error classes to import. Assert against **JSON-RPC error codes** (`-32601` method-not-found, `-32602` invalid-params, etc.) or **HTTP status codes** (`401 Unauthorized`, `408 Request Timeout`, `429 Too Many Requests`), depending on your transport.\n\n```typescript\nit(\"surfaces auth errors\", async () => {\n  const failingClient = {\n    callTool: async () => {\n      // Mirror the wire shape: an HTTP-401 surface from the gateway becomes a\n      // JSON-RPC error on the MCP client.\n      const err = new Error(\"Unauthorized\") as Error & { code?: number };\n      err.code = -32000; // JSON-RPC server-error range; httpStatus 401 upstream.\n      throw err;\n    },\n  };\n\n  await expect(\n    failingClient.callTool({ name: \"ggui_handshake\", arguments: {} })\n  ).rejects.toMatchObject({ code: -32000 });\n});\n```\n\nSee [Error Handling](/cookbook/error-handling/) for retry, fallback, and graceful-degradation patterns built on these protocol-level signals.\n\n:::tip[Self-hosted parity]\nThe transport-mock boundary is endpoint-agnostic — the same canned responses serve whether your agent points at a self-hosted `ggui serve` (`http://127.0.0.1:6781/mcp`, WebSocket `ws://127.0.0.1:6781/ws`) or the hosted `mcp.ggui.ai` endpoint. Mock the transport, not the endpoint.\n:::\n\n---\n\n## What not to test\n\nLLM-generated component code is non-deterministic — assert **behavior**, not DOM structure. Pin contracts (via `defineContract` + `useContract`) and test your agent's tool-call sequence. Leave the visual layer to live snapshots or a separate generation-quality tier.\n\nSee also: [@ggui-ai/mcp-apps-react](/host-helpers/react/) · [MCP Protocol](/api/mcp-protocol/) · [Troubleshooting](/troubleshooting/)."},{"path":"/design/tokens.md","title":"Design Tokens","description":"Visual reference for the DTCG design tokens that GGUI primitives consume — colors, spacing, typography, radius, and shadows.","body":"---\ntitle: Design Tokens\ndescription: Visual reference for the DTCG design tokens that GGUI primitives consume — colors, spacing, typography, radius, and shadows.\n---\n\nEvery `@ggui-ai/design` primitive — and by extension every gadget that ships with the OSS renderer — reads its visual state from [DTCG](https://design-tokens.github.io/community-group/format/) tokens exposed as CSS custom properties. Tokens themselves need no runtime: override the variables on any ancestor and the cascade does the rest on the next paint.\n\nOperator theming builds on these same variables: `ggui.json#theme` selects one of the shipped presets (or a DTCG file), and an app's own theme is stored as its full projection for both modes. Every write door refuses a theme that leaves a variable the components read unset, and the iframe-runtime merges the theme into the scoped token block the components read. A partial change is still fine where you author it: a preset plus a few `overrides` is projected in full, both modes, before it is stored. Agents can enumerate presets with `ggui_list_themes` and pick one per render via `ggui_render({themeId})` — see [Custom Theming](/cookbook/custom-theming/).\n\n```css\n/* Every primitive uses var(--ggui-…) with a hardcoded fallback */\ncolor: var(--ggui-color-primary-600, #1f1f1f);\npadding: var(--ggui-spacing-md, 16px);\nborder-radius: var(--ggui-shape-radius-md, 2px);\n```\n\n:::tip\nThe values below are the shipped default preset (`ggui`) in light mode, projected into `--ggui-color-…`, `--ggui-spacing-…`, `--ggui-shape-radius-…`, `--ggui-shape-shadow-…` and `--ggui-motion-…` variables. The projection emits one fixed set of names for every shipped preset in both modes (listed in the [Custom Theming token reference](/cookbook/custom-theming/#token-reference)); a theme changes the values, never the names. Primitives reference `--ggui-shape-radius-md`, `--ggui-shape-shadow-md`, `--ggui-motion-duration-base`, etc.\n:::\n\n:::caution\nThe swatch/scale tables below are rendered from a hand-maintained snapshot at `apps/docs/src/data/tokens.ts` (Astro can't import the React design package directly). If a value here disagrees with what `@ggui-ai/design` emits, the design package is authoritative.\n:::\n\n---\n\n## Colors\n\n### Color Palettes\n\n{colorGroups.map((group) => (\n  <div style=\"margin-bottom: 32px;\">\n    <h4 style=\"margin-bottom: 12px;\">{group.name}</h4>\n    <div style=\"display: flex; flex-wrap: wrap; gap: 8px;\">\n      {group.swatches.map((swatch) => (\n        <TokenSwatch hex={swatch.hex} shade={swatch.shade} cssVar={swatch.cssVar} />\n      ))}\n    </div>\n  </div>\n))}\n\n### Semantic Colors\n\nThese tokens map to specific UI roles and adapt between light and dark themes.\n\n<div class=\"semantic-grid\">\n  {semanticColors.map((color) => (\n    <div class=\"semantic-row\">\n      <div class=\"semantic-swatch\" style={`background-color: ${color.hex}; border: 1px solid rgba(0,0,0,0.1);`} />\n      <div class=\"semantic-meta\">\n        <strong>{color.name}</strong>\n        <code>{color.hex}</code>\n        <code class=\"semantic-var\">{color.cssVar}</code>\n      </div>\n    </div>\n  ))}\n</div>\n\n:::note\n**Status colors are scales, not singletons.** `success`, `warning` and `info` each expose the 50/100/200/500/700/800 stops, and `error` adds a 600. Reach for `--ggui-color-success-500` for fills, the darker stops for hover/pressed states, and `-50/-100` for subtle tints — the singletons that used to live here are retired.\n:::\n\n### Material Role Pairs\n\nThe canonical theme states its layering as role pairs — a background and the ink that sits on it. A `theme.json` authors three of the four (`ground`, `container`, `sunken`, each with its `on*`); `elevated` is derived. Primitives use these to keep contrast right across nested backgrounds.\n\n| CSS variable                | Role                                                          |\n| --------------------------- | ------------------------------------------------------------- |\n| `--ggui-color-ground`       | The canvas — page / sheet background                          |\n| `--ggui-color-onGround`     | Primary text and icons on `ground`                            |\n| `--ggui-color-container`    | Cards, panels, filled containers                              |\n| `--ggui-color-onContainer`  | Text/icons on `container`                                     |\n| `--ggui-color-elevated`     | Raised surfaces (menus, popovers) — derived, not authored     |\n| `--ggui-color-onElevated`   | Text/icons on `elevated` — derived                            |\n| `--ggui-color-sunken`       | Wells, inputs at rest, code blocks — recessed inside `container` |\n| `--ggui-color-onSunken`     | Secondary text/icons on `sunken`                              |\n| `--ggui-color-outline`             | Standard borders + dividers                     |\n| `--ggui-color-outlineVariant`      | Faint borders, disabled outlines                |\n| `--ggui-color-onPrimary`           | Text/icons on a `primary` fill (CTA labels)     |\n| `--ggui-color-primaryContainer`    | Tinted primary container (soft CTA, highlight)  |\n| `--ggui-color-onPrimaryContainer`  | Text/icons on `primaryContainer`                |\n| `--ggui-color-onError`             | Text/icons on an `error` fill                   |\n| `--ggui-color-errorContainer`      | Softer error container (inline error banners)   |\n| `--ggui-color-onErrorContainer`    | Text/icons on `errorContainer`                  |\n| `--ggui-color-tertiaryContainer`   | Tinted tertiary container                       |\n| `--ggui-color-onTertiaryContainer` | Text/icons on `tertiaryContainer`               |\n\n<style>{`\n  .semantic-grid {\n    display: flex;\n    flex-direction: column;\n    gap: 8px;\n    margin: 16px 0;\n  }\n  .semantic-row {\n    display: flex;\n    align-items: center;\n    gap: 12px;\n  }\n  .semantic-swatch {\n    width: 40px;\n    height: 40px;\n    border-radius: 8px;\n    flex-shrink: 0;\n  }\n  .semantic-meta {\n    display: flex;\n    align-items: baseline;\n    gap: 8px;\n  }\n  .semantic-meta strong {\n    font-size: 14px;\n    min-width: 120px;\n  }\n  .semantic-meta code {\n    font-size: 12px;\n    background: none !important;\n    border: none !important;\n    padding: 0 !important;\n    color: var(--sl-color-gray-3);\n  }\n  .semantic-var {\n    font-size: 11px !important;\n    color: var(--sl-color-gray-3) !important;\n  }\n`}</style>\n\n---\n\n## Spacing\n\nA consistent spacing scale ensures visual rhythm across all components.\n\n<SpacingScale tokens={spacingTokens} />\n\n---\n\n## Typography\n\nCanonical theme path: `font.{family,weight,ramp,letterSpacing}` — a theme authors the families, the weights, one `font.ramp` and optional letter-spacing; the size and line-height scales are derived from the ramp. Emitted as `--ggui-font-family-*`, `--ggui-font-weight-*`, and the derived `--ggui-font-size-*` / `--ggui-font-lineHeight-*` shown below. Web fonts are declared as `typography.faces` (`https:` sources only); `ggui deploy` names any family the hosted path does not deliver yet. A face's origin is admitted to the card's `font-src` when the card mounts, so a face added on a new origin while a card is already open loads on that card's next mount. Until then the runtime reports the blocked face to the embedding host as a `font-face-blocked` event, the same event it posts for a blocked host-announced font. Authored `font.size` or `font.lineHeight` is refused.\n\n<TypographySample\n  fontSizes={fontSizeTokens}\n  fontWeights={fontWeightTokens}\n  fontFamilies={fontFamilies}\n  lineHeights={lineHeightTokens}\n/>\n\n---\n\n## Border Radius\n\nCanonical theme path: `shape.radius.*`. Emitted as `--ggui-shape-radius-{sm,md,lg,xl,full,control}`.\n\n<div class=\"radius-grid\">\n  {radiusTokens.map((token) => (\n    <div class=\"radius-card\">\n      <div\n        class=\"radius-preview\"\n        style={`border-radius: ${token.value};`}\n      />\n      <div class=\"radius-meta\">\n        <code class=\"radius-name\">{token.name}</code>\n        <span class=\"radius-value\">{token.value}</span>\n      </div>\n      <code class=\"radius-var\">{token.cssVar}</code>\n    </div>\n  ))}\n</div>\n\n<style>{`\n  .radius-grid {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 16px;\n    margin: 16px 0;\n  }\n  .radius-card {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    gap: 8px;\n    min-width: 80px;\n  }\n  .radius-preview {\n    width: 64px;\n    height: 64px;\n    background: var(--sl-color-accent);\n    opacity: 0.2;\n    border: 2px solid var(--sl-color-accent);\n  }\n  .radius-meta {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    gap: 2px;\n  }\n  .radius-name {\n    font-size: 13px;\n    font-weight: 600;\n    background: none !important;\n    border: none !important;\n    padding: 0 !important;\n  }\n  .radius-value {\n    font-size: 11px;\n    color: var(--sl-color-gray-3);\n  }\n  .radius-var {\n    font-size: 10px;\n    color: var(--sl-color-gray-3);\n    background: none !important;\n    border: none !important;\n    padding: 0 !important;\n  }\n`}</style>\n\n---\n\n## Shadows\n\nCanonical theme path: `shape.shadow.*`. Emitted as `--ggui-shape-shadow-{sm,md,lg,xl}`.\n\n<ShadowPreview shadows={shadowTokens} />\n\n---\n\n## Motion\n\nEvery design-system transition is composed from six variables: three duration steps and three easing roles. A theme may state its own tempo (`motion.duration.{fast,base,slow}`, `motion.easing.{standard,emphasized,exit}`), and since v0.21.0 the card animates at the stated tempo; a theme that states none gets the shipped defaults below.\n\n| Variable                          | Shipped default                | Role                                            |\n| --------------------------------- | ------------------------------ | ----------------------------------------------- |\n| `--ggui-motion-duration-fast`     | `100ms`                        | Hover, focus, small swaps                       |\n| `--ggui-motion-duration-base`     | `200ms`                        | The default for colour, opacity and transforms  |\n| `--ggui-motion-duration-slow`     | `300ms`                        | Larger changes                                  |\n| `--ggui-motion-easing-standard`   | `cubic-bezier(0.4, 0, 0.2, 1)` | The ease-in-out for colour, border and size     |\n| `--ggui-motion-easing-emphasized` | `cubic-bezier(0, 0, 0.2, 1)`   | The decelerating arrival — transforms, entrances |\n| `--ggui-motion-easing-exit`       | `cubic-bezier(0.4, 0, 1, 1)`   | The accelerating departure                      |\n\nThere are no pre-composed `transition` variables: write the shorthand yourself from a duration and an easing, naming the properties you move. Keyframe animations (spinners, pulses, shimmers) are not on this scale. A theme's `motion.keyframes` become `@keyframes ggui-<name>` rules rather than variables. Its other motion members (`transition`, `reduce`) and its `accessibility` and `zIndex` groups are accepted in the document, but they emit no `--ggui-*` variable, so no component reads them.\n\n---\n\n## Using Tokens\n\n### In primitives\n\nNothing to wire — every primitive already consumes the tokens:\n\n```tsx\nimport { Button, Card, Input } from '@ggui-ai/design';\n\n<Card>\n  <Input placeholder=\"Enter your name\" />\n  <Button variant=\"primary\">Submit</Button>\n</Card>;\n```\n\n### In your own components\n\nReference tokens by their CSS variable, with a hardcoded fallback for environments where the theme provider hasn't loaded yet:\n\n```css\n.my-component {\n  background: var(--ggui-color-ground, #f4f3ed);\n  padding: var(--ggui-spacing-md, 16px);\n  border-radius: var(--ggui-shape-radius-lg, 4px);\n  box-shadow: var(--ggui-shape-shadow-md, 0 1px 2px rgba(41, 41, 41, 0.06));\n  font-family: var(--ggui-font-family-sans, system-ui, -apple-system, sans-serif);\n  font-size: var(--ggui-font-size-sm, 14px);\n  color: var(--ggui-color-onGround, #292929);\n  transition: color var(--ggui-motion-duration-base, 200ms) var(--ggui-motion-easing-standard, ease);\n}\n```\n\n### Theming\n\nOverride on any element — `:root` for global, a wrapper for per-subtree:\n\n```css\n:root {\n  --ggui-color-primary-600: #7c3aed;     /* Purple instead of the default ink */\n  --ggui-color-ground: #fefce8;          /* Warm paper canvas */\n  --ggui-color-onGround: #1f1410;        /* Ink for the new ground */\n  --ggui-shape-radius-md: 16px;          /* Pillier corners */\n  --ggui-motion-duration-base: 240ms;    /* Slightly more deliberate */\n}\n```\n\nSee [Custom Theming](/cookbook/custom-theming/) for the full recipe — global overrides, dark-mode pairs, and scoped subtrees."},{"path":"/examples/claude-agent.md","title":"Claude Agent","description":"Drive GGUI from Claude Agent SDK's tool-use loop — Claude calls GGUI MCP tools directly, no client SDK in your code.","body":"---\ntitle: Claude Agent\ndescription: Drive GGUI from Claude Agent SDK's tool-use loop — Claude calls GGUI MCP tools directly, no client SDK in your code.\n---\n\n:::note[Hosted GGUI]\nThis example targets a local `ggui serve --dev-allow-all` (`http://127.0.0.1:6781/mcp`, `Authorization: Bearer dev` — local dev only; default `ggui serve` requires a paired bearer). Using hosted GGUI instead? `ggui keys create` mints a `ggui_user_…` connector key, and your per-app endpoint is `https://mcp.ggui.ai/apps/<appId>` — only the URL and bearer change.\n:::\n\nThis is the canonical Claude pattern for ggui. You wire GGUI as an MCP server in Claude Agent SDK's `mcpServers` config, hand Claude the GGUI posture prompt (`GGUI_AGENT_SYSTEM_PROMPT`), and Claude decides when to call `ggui_handshake` / `ggui_render` / `ggui_consume` based on the tool descriptions it discovers via `tools/list`. **Zero GGUI SDK code in your app** — Claude drives the protocol directly.\n\nA runnable version lives at [`samples/agents/claude-agent-sdk/`](https://github.com/ggui-ai/ggui/tree/main/samples/agents/claude-agent-sdk). It runs the same `query()` loop, wrapped in an HTTP agent backend via `@ggui-ai/agent-server` — so it reads `GGUI_MCP_URL` / `GGUI_MCP_BEARER` from the environment instead of hard-coding the endpoint the way the snippet below does.\n\n## Install\n\n```bash\nnpm install @anthropic-ai/claude-agent-sdk @ggui-ai/protocol\n```\n\n```bash\nexport ANTHROPIC_API_KEY=\"sk-ant-...\"\n```\n\n## Code\n\n```typescript\n// claude-agent.ts\nimport { query } from \"@anthropic-ai/claude-agent-sdk\";\nimport { GGUI_AGENT_SYSTEM_PROMPT } from \"@ggui-ai/protocol\";\n\n// 1. Wire ggui as an MCP server. Claude Agent SDK will issue `tools/list`\n//    against this URL on session start and discover every `ggui_*` tool.\n//    `Bearer dev` authenticates because `ggui serve --dev-allow-all` accepts\n//    any bearer — local dev only.\nconst mcpServers = {\n  ggui: {\n    type: \"http\" as const,\n    url: \"http://127.0.0.1:6781/mcp\",\n    headers: { Authorization: \"Bearer dev\" },\n  },\n};\n\n// 2. Whitelist the ggui tools Claude is allowed to call. The SDK auto-namespaces\n//    every tool as `mcp__<serverName>__<toolName>`, so the prefix is `mcp__ggui__`.\nconst GGUI_ALLOWED_TOOLS = [\n  \"mcp__ggui__ggui_handshake\",\n  \"mcp__ggui__ggui_render\",\n  \"mcp__ggui__ggui_update\",\n  \"mcp__ggui__ggui_amend\",\n  \"mcp__ggui__ggui_emit\",\n  \"mcp__ggui__ggui_consume\",\n  \"mcp__ggui__ggui_get_session\",\n];\n\nasync function chat(userPrompt: string) {\n  console.log(`\\nUser: ${userPrompt}\\n`);\n\n  // 3. `query()` returns an AsyncGenerator of SDKMessage events. The SDK runs\n  //    the full tool-use loop internally — you just consume the stream.\n  for await (const message of query({\n    prompt: userPrompt,\n    options: {\n      // Any current Claude id works here — `claude-fable-5-1` for the most\n      // capable model, `claude-haiku-5-5` for the cheapest.\n      model: \"claude-sonnet-5\",\n      mcpServers,\n      allowedTools: GGUI_ALLOWED_TOOLS,\n      // The posture-only canonical ggui prompt. Tells Claude *when* to reach\n      // for a UI; tool descriptions tell it *how*.\n      systemPrompt: GGUI_AGENT_SYSTEM_PROMPT,\n    },\n  })) {\n    if (message.type === \"assistant\") {\n      for (const block of message.message.content) {\n        if (block.type === \"text\") {\n          process.stdout.write(block.text);\n        } else if (block.type === \"tool_use\") {\n          console.log(`\\n[tool_use] ${block.name}`);\n        }\n      }\n    } else if (message.type === \"user\") {\n      // Tool results flow back as user-role messages from the SDK's perspective.\n      for (const block of message.message.content) {\n        if (block.type === \"tool_result\") {\n          console.log(`[tool_result] ${block.tool_use_id}`);\n        }\n      }\n    } else if (message.type === \"result\") {\n      console.log(`\\n\\n[done] subtype=${message.subtype}`);\n    }\n  }\n}\n\nchat(\"I want to book a restaurant reservation for this weekend\").catch(console.error);\n```\n\n## Run\n\n```bash\nnpx tsx claude-agent.ts\n```\n\n## What you'll see\n\n`query()` yields a stream of `SDKMessage` events. The shapes you'll observe in a typical GGUI turn:\n\n- **`system` (init)** — opening event listing the model, allowed tools, and MCP servers Claude discovered.\n- **`assistant`** — Claude's response chunks. `content` is an array of blocks:\n  - `text` — natural-language reply (streamed across multiple events).\n  - `tool_use` — Claude invoking a GGUI tool (e.g. `mcp__ggui__ggui_handshake`). The `input` field contains the tool arguments Claude generated.\n  - `thinking` — extended-thinking blocks when reasoning is on.\n- **`user`** — tool results flowing back into the loop. `content[].type === \"tool_result\"` with `tool_use_id` pointing at the matching `tool_use`.\n- **`result`** — terminal event with `subtype` (`success`, `error_max_turns`, `error_during_execution`, …), `usage` totals, and `total_cost_usd`.\n\nA reservation booking typically streams: `system` → `assistant`(text + `tool_use:ggui_handshake`) → `user`(tool_result) → `assistant`(`tool_use:ggui_render`) → `user`(tool_result with `sessionId` + `resourceUri` — the host mounts the UI from the MCP-Apps resource) → \\[user submits the form\\] → `assistant`(`tool_use:ggui_consume`) → `user`(tool_result with submission data) → `assistant`(confirmation text) → `result`.\n\n## Patterns worth stealing\n\n- **Zero Agent Code.** Your file imports `query` and `GGUI_AGENT_SYSTEM_PROMPT` and lists tool names — nothing else. The protocol lives behind MCP; Claude reads tool descriptions and drives it directly.\n- **`GGUI_AGENT_SYSTEM_PROMPT` is posture, not instructions.** It tells Claude _when_ to reach for a UI (structured input, choices, visual presentation). Per-tool semantics ship from the server in `tools/list` — you don't restate them.\n- **Tool namespace is structural.** `mcp__<server>__<tool>` is non-negotiable; it's how the SDK routes calls. Match the prefix exactly in `allowedTools`.\n- **Whitelist explicitly.** The agent endpoint mounts two audiences — `ggui_*` agent tools and `ggui_runtime_*` tools the rendered iframe calls on its own behalf. (Operator and spec tools — `ggui_ops_*`, `ggui_protocol_*` — are not here; they live on the separate [control plane](/api/control-mcp/).) Omit `allowedTools` and Claude can reach the runtime tools too, which is never what you want. The list above is the minimal agent-facing surface; the [runnable sample](https://github.com/ggui-ai/ggui/tree/main/samples/agents/claude-agent-sdk) adds `ggui_list_sessions` plus the blueprint and discovery tools. `allowedTools` matches exact names — there is no `mcp__ggui__*` wildcard, so each tool must be listed.\n- **Prompt caching is automatic** when the SDK detects a stable `systemPrompt` + `mcpServers` prefix across turns. No `cache_control` plumbing needed in your code.\n\n## Related\n\n- [`samples/agents/claude-agent-sdk/`](https://github.com/ggui-ai/ggui/tree/main/samples/agents/claude-agent-sdk) — runnable reference for this pattern\n- [OpenAI agent example](/examples/openai-agent/) — same shape, function-calling transport\n- [`GGUI_AGENT_SYSTEM_PROMPT` reference](/api/mcp-protocol/) — what's in the posture prompt and why\n- [How it works](/how-it-works/) — what happens between `ggui_render` and the rendered UI"},{"path":"/examples/gemini-agent.md","title":"Gemini Agent","description":"Wire Google Gemini's function-calling API to GGUI via MCP — bridge GGUI's MCP tools into Gemini function declarations and continue the conversation once the user submits.","body":"---\ntitle: Gemini Agent\ndescription: Wire Google Gemini's function-calling API to GGUI via MCP — bridge GGUI's MCP tools into Gemini function declarations and continue the conversation once the user submits.\n---\n\n:::note[Hosted GGUI]\nThis example targets a local `ggui serve --dev-allow-all` (`http://127.0.0.1:6781/mcp`, `Authorization: Bearer dev` — local dev only; default `ggui serve` requires a paired bearer). Using hosted GGUI instead? Swap in `https://mcp.ggui.ai/apps/<appId>` and a `ggui_user_…` connector key from `ggui keys create` — the bridge pattern is identical.\n:::\n\nWire GGUI to Gemini's function-calling API so the model can mint a UI mid-conversation, wait for the user to submit, and continue with the structured data. The pattern is a **thin bridge**: connect to GGUI's MCP server with the official `@modelcontextprotocol/sdk` client, then surface every MCP tool as a Gemini `FunctionDeclaration`.\n\nThe runnable Google-side reference in the repo takes the other route: [`samples/agents/google-adk/`](https://github.com/ggui-ai/ggui/tree/main/samples/agents/google-adk) uses Google's Agent Development Kit (`@google/adk`), whose `MCPToolset` speaks MCP directly, so no hand-rolled bridge is needed. Read this page when you want the raw `@google/genai` call surface; read that sample when you want the framework to do the wiring.\n\n## Setup\n\n```bash\nnpm install @google/genai @modelcontextprotocol/sdk\n```\n\n```bash\nexport GEMINI_API_KEY=\"AIza...\"\n```\n\n## Code\n\n```typescript\n// gemini-agent.ts\nimport { GoogleGenAI } from \"@google/genai\";\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\n\nconst ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });\n\n// 1. Connect to ggui over MCP (Streamable HTTP transport). `Bearer dev`\n//    authenticates because `ggui serve --dev-allow-all` accepts any bearer —\n//    local dev only.\nconst mcpClient = new Client({ name: \"gemini-ggui-agent\", version: \"0.1.0\" }, {});\n\nawait mcpClient.connect(\n  new StreamableHTTPClientTransport(new URL(\"http://127.0.0.1:6781/mcp\"), {\n    requestInit: {\n      headers: { Authorization: \"Bearer dev\" },\n    },\n  })\n);\n\n// 2. Bridge MCP tools → Gemini function declarations.\nconst { tools: mcpTools } = await mcpClient.listTools();\nconst geminiTools = [\n  {\n    functionDeclarations: mcpTools.map((t) => ({\n      name: t.name,\n      description: t.description,\n      // Gemini takes JSON Schema as-is via parametersJsonSchema (NOT `parameters`).\n      parametersJsonSchema: t.inputSchema,\n    })),\n  },\n];\n\n// 3. Open a chat. `chats.create` keeps multi-turn state — reuse this handle.\nconst chat = ai.chats.create({\n  model: \"gemini-3.5-flash\",\n  config: {\n    tools: geminiTools,\n    systemInstruction:\n      \"You drive ggui MCP tools to render interactive UIs. Call the appropriate tool when you need to collect structured data from the user, then continue with their response.\",\n  },\n});\n\nasync function run(userPrompt: string) {\n  console.log(`\\nUser: ${userPrompt}`);\n\n  let response = await chat.sendMessage({ message: userPrompt });\n\n  // 4. Drain function calls until the model returns plain text.\n  while (response.functionCalls?.length) {\n    const functionResponses = await Promise.all(\n      response.functionCalls.map(async (fc) => {\n        const result = await mcpClient.callTool({\n          name: fc.name,\n          arguments: fc.args,\n        });\n        return { name: fc.name, response: { content: result.content } };\n      })\n    );\n\n    response = await chat.sendMessage({\n      message: functionResponses.map((fr) => ({ functionResponse: fr })),\n    });\n  }\n\n  console.log(`\\nAssistant: ${response.text ?? \"(no response)\"}`);\n}\n\nawait run(\"I need to schedule a meeting with my team for next week\");\n\nawait mcpClient.close();\n```\n\n## Run\n\n```bash\nnpx tsx gemini-agent.ts\n```\n\n## What Happens\n\n1. You ask Gemini to schedule a team meeting.\n2. Gemini calls `ggui_handshake({intent, blueprintDraft})` and gets a `handshakeId` + a `suggestion` (the server matches a [blueprint](/glossary/) or synthesizes one).\n3. Gemini calls `ggui_render({handshakeId, props})`; the result carries `{sessionId, resourceUri}` — your host mounts the UI from that MCP-Apps resource.\n4. Gemini calls `ggui_consume({sessionId, timeout})`; when the user submits, the `events` array delivers `{intent, actionData, uiContext}`.\n5. Gemini replies with plain text.\n\n## Gemini-specific notes\n\n- Function schemas use **`parametersJsonSchema`** (JSON Schema passthrough), not the older `parameters` (subset-of-OpenAPI) field. MCP tools expose `inputSchema` as JSON Schema — pass it through unchanged.\n- Tools are grouped under `functionDeclarations`; tool replies are `functionResponse` parts inside the `message: PartListUnion`.\n- Multi-turn state lives on the `ai.chats.create()` handle — reuse `chat` across turns. Don't call `models.generateContent` for chat flows or you lose history.\n- Tool-loop latency tip: set `config.thinkingConfig.thinkingLevel` to `MINIMAL` on the `chats.create()` call — Gemini 3 models default to a high thinking level, which adds tens of seconds per tool turn. (`MINIMAL` is the floor; the enum has no \"off\".)\n- The bridge is generic: every tool GGUI exposes over MCP becomes a Gemini function declaration automatically. No per-tool wrapper code.\n\n## Next\n\n- [Claude Agent example](/examples/claude-agent/) — same MCP bridge pattern with Anthropic's SDK (native MCP server support — no manual bridge needed).\n- [OpenAI Agent example](/examples/openai-agent/) — same flow with OpenAI function calling.\n- [How it works](/how-it-works/) — the three channels (bootstrap, MCP, WebSocket) and envelope shapes."},{"path":"/examples/generic-mcp.md","title":"Generic MCP / Raw HTTP","description":"Drive GGUI from any MCP-compatible agent or via raw JSON-RPC over HTTP — official MCP client library, and language-agnostic curl/Python recipes.","body":"---\ntitle: Generic MCP / Raw HTTP\ndescription: Drive GGUI from any MCP-compatible agent or via raw JSON-RPC over HTTP — official MCP client library, and language-agnostic curl/Python recipes.\n---\n\n:::note[One wire, two endpoints]\nThe wire format is identical across any self-hosted `ggui serve` and the hosted endpoint. Only the URL and auth header change. Examples below default to a local `ggui serve --dev-allow-all`; see [Hosted vs self-hosted](#hosted-vs-self-hosted--what-to-swap) before the raw-HTTP walkthrough.\n:::\n\nUse GGUI from any language or framework — through the official MCP TypeScript client, or raw HTTP with JSON-RPC. There is no ggui-specific wrapper SDK: every endpoint is a vanilla MCP server, so any spec-compliant client works.\n\n> Building on top of the Claude Agent SDK instead? See [Claude Agent SDK example](/examples/claude-agent/) — it wires GGUI in as a stock MCP server with no extra glue.\n\n## Using the MCP client library (TypeScript)\n\nThe official `@modelcontextprotocol/sdk` package speaks the MCP wire end-to-end. Point its `StreamableHTTPClientTransport` at `http://127.0.0.1:6781/mcp` and call GGUI's tools by name.\n\n```typescript\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\n\nconst client = new Client({ name: \"my-agent\", version: \"0.1.0\" }, {});\n\n// `Bearer dev` authenticates because `ggui serve --dev-allow-all` accepts\n// any bearer — local dev only.\nawait client.connect(\n  new StreamableHTTPClientTransport(new URL(\"http://127.0.0.1:6781/mcp\"), {\n    requestInit: {\n      headers: {\n        Authorization: \"Bearer dev\",\n      },\n    },\n  })\n);\n\n// Discover the tool surface.\nconst { tools } = await client.listTools();\nconsole.log(\n  \"Available tools:\",\n  tools.map((t) => t.name)\n);\n// → ['ggui_handshake', 'ggui_render', 'ggui_consume',\n//    'ggui_amend', 'ggui_update', 'ggui_emit',\n//    'ggui_get_session', 'ggui_get_render_source', 'ggui_list_sessions',\n//    'ggui_list_gadgets', 'ggui_list_themes',\n//    'ggui_list_featured_blueprints', 'ggui_search_blueprints',\n//    'ggui_render_blueprint']\n\n// handshake → render at the raw MCP layer.\nconst handshakeResult = await client.callTool({\n  name: \"ggui_handshake\",\n  arguments: {\n    intent: \"feedback form\",\n    blueprintDraft: {\n      contract: {/* DataContract — propsSpec, actionSpec, contextSpec, streamSpec */},\n    },\n  },\n});\nconst handshake = JSON.parse(handshakeResult.content[0].text) as {\n  handshakeId: string;\n  suggestion: {\n    origin: \"cache\" | \"agent\" | \"synth\";\n    blueprintMeta: { blueprintId?: string; contractHash: string };\n  };\n};\n\nconst renderResult = await client.callTool({\n  name: \"ggui_render\",\n  arguments: {\n    handshakeId: handshake.handshakeId,\n    props: {},\n  },\n});\nconst { sessionId, resourceUri } = JSON.parse(renderResult.content[0].text) as {\n  sessionId: string;\n  resourceUri: string;\n};\nconsole.log(\"Render:\", sessionId, \"→\", resourceUri);\n```\n\nThe three-noun model: a **tool** is what the agent calls (the MCP methods above); a **gadget** is a renderer-side capability the LLM may opt into when generating the UI; a **blueprint** is a cached recipe the handshake returns as a `suggestion` (with `origin: 'cache' | 'agent' | 'synth'`) — accepting it on render reuses the provisional `blueprintId` and skips regeneration.\n\nNeed a higher-level Claude-flavored shortcut? The [Claude Agent SDK example](/examples/claude-agent/) registers GGUI as an MCP server and lets the agent loop drive `ggui_handshake` / `ggui_render` / `ggui_consume` on its own.\n\n---\n\n## Using raw HTTP (no SDK)\n\nCall MCP over JSON-RPC from any language. The flow is **`initialize` → `ggui_handshake` → `ggui_render` → `ggui_consume`** (poll, keyed by `sessionId`). There is no explicit close — renders stay reopenable.\n\n### Hosted vs self-hosted — what to swap\n\nEvery `curl` below targets a local `ggui serve --dev-allow-all`. To drive the hosted endpoint instead, swap two values:\n\n| What            | Self-hosted (`ggui serve`) — default                                 | Hosted (`mcp.ggui.ai`)                                       |\n| --------------- | -------------------------------------------------------------------- | ------------------------------------------------------------ |\n| Endpoint URL    | `http://127.0.0.1:6781/mcp`                                          | `https://mcp.ggui.ai`, or `https://mcp.ggui.ai/apps/<appId>` |\n| `Authorization` | `Bearer dev` (requires `ggui serve --dev-allow-all`; local dev only) | `Bearer ggui_user_...`                                       |\n\nThe `/mcp` path suffix belongs to `ggui serve` only — the hosted endpoint has none. Use the bare `https://mcp.ggui.ai` when your key should reach your default app, and the per-app `https://mcp.ggui.ai/apps/<appId>` when you want to pin one app explicitly.\n\n:::caution[`--dev-allow-all` is for local dev only]\n`--dev-allow-all` accepts any bearer — keep the default `127.0.0.1` bind and never expose it publicly. For real bearers use pair-minted ones (the strict default) — see [Pairing](/self-hosted/pairing/), and `--keys-file` to persist them across restarts.\n:::\n\n### Step 1 — Initialize the MCP session\n\n```bash\ncurl -X POST http://127.0.0.1:6781/mcp \\\n  -H \"Authorization: Bearer dev\" \\\n  -H \"Accept: application/json, text/event-stream\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"jsonrpc\": \"2.0\",\n    \"id\": 1,\n    \"method\": \"initialize\",\n    \"params\": {\n      \"protocolVersion\": \"2025-11-25\",\n      \"clientInfo\": { \"name\": \"my-agent\", \"version\": \"1.0\" },\n      \"capabilities\": {}\n    }\n  }'\n```\n\n`protocolVersion` here is the MCP transport spec date, not the GGUI protocol draft. The `Accept` header is mandatory — the server rejects requests that don't accept both `application/json` and `text/event-stream`. Responses come back as a single SSE event (`event: message` + `data: {...}`); parse the `data:` line as JSON — the `# → {...}` comments below show that parsed payload.\n\n### Step 2 — Negotiate a handshake\n\n```bash\ncurl -X POST http://127.0.0.1:6781/mcp \\\n  -H \"Authorization: Bearer dev\" \\\n  -H \"Accept: application/json, text/event-stream\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"jsonrpc\": \"2.0\",\n    \"id\": 2,\n    \"method\": \"tools/call\",\n    \"params\": {\n      \"name\": \"ggui_handshake\",\n      \"arguments\": {\n        \"intent\": \"Contact form\",\n        \"blueprintDraft\": {\n          \"contract\": {\n            \"propsSpec\": {\n              \"properties\": {\n                \"fields\": { \"schema\": { \"type\": \"array\", \"items\": {} } }\n              }\n            },\n            \"actionSpec\": {\n              \"submit\": { \"label\": \"Submit\", \"schema\": { \"type\": \"object\" } }\n            }\n          }\n        }\n      }\n    }\n  }'\n# → { handshakeId, action, suggestion, nextStep? }\n```\n\nThe returned `suggestion.origin` is the routing discriminator: `'cache'` (a cached blueprint matched — render with `{handshakeId, props}` and omit `override` for a cheap cache delivery), `'agent'` (gen against the draft on render), or `'synth'` (gen against a server-amended contract). `suggestion.blueprintMeta` is always present; it carries a `blueprintId` when the server matched or pre-minted one. See [`ggui_handshake`](/api/mcp-protocol/#ggui_handshake) for the full input + output schemas.\n\n### Step 3 — Render the UI\n\n```bash\ncurl -X POST http://127.0.0.1:6781/mcp \\\n  -H \"Authorization: Bearer dev\" \\\n  -H \"Accept: application/json, text/event-stream\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"jsonrpc\": \"2.0\",\n    \"id\": 3,\n    \"method\": \"tools/call\",\n    \"params\": {\n      \"name\": \"ggui_render\",\n      \"arguments\": {\n        \"handshakeId\": \"h_...\",\n        \"props\": {}\n      }\n    }\n  }'\n# → { sessionId, resourceUri, action, contractHash, blueprintId, variantKey, cache, nextStep? }\n```\n\n`props` is REQUIRED (pass `{}` when the contract declares no `propsSpec`); omit `override` to accept the suggestion as-is, or pass `override: {contract?, variance?}` to re-aim. The render comes back as an MCP-Apps resource (`resourceUri`) a host mounts — there is no URL to hand the user; you poll for their actions with `sessionId`.\n\n### Step 4 — Poll for events\n\n```bash\ncurl -X POST http://127.0.0.1:6781/mcp \\\n  -H \"Authorization: Bearer dev\" \\\n  -H \"Accept: application/json, text/event-stream\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"jsonrpc\": \"2.0\",\n    \"id\": 4,\n    \"method\": \"tools/call\",\n    \"params\": {\n      \"name\": \"ggui_consume\",\n      \"arguments\": { \"sessionId\": \"4f6b2c0e-…\", \"timeout\": 20 }\n    }\n  }'\n# → { events: ConsumeEventEntry[], status: \"active\" | \"expired\" }\n```\n\nConsume is keyed by the `sessionId` from step 3. `timeout` (seconds): an integer in `[0, 25]`; `0` = immediate. Values outside that range reject with `INVALID_PARAMS` (-32602) — the cap dodges infrastructure kill windows (API-gateway 30s HTTP limits). Pick 5–15s typically, 25 max; to wait longer, re-call `ggui_consume` in a loop — a longer wait is your loop, not a bigger timeout. For push-style delivery, prefer the [WebSocket Protocol](/api/websocket-protocol/) over polling; raw HTTP callers loop `ggui_consume` per render. Each entry has `{type: 'action', sessionId, intent, actionData, uiContext, actionId, firedAt}`. See [Envelopes](/protocol/envelopes/) for the wire shape.\n\nThere is no explicit close ceremony — renders stay reopenable for as long as the deployment keeps them (indefinitely on hosted GGUI). There is no per-render time limit.\n\n---\n\n## Python example\n\nA complete end-to-end run with the standard library and `requests`:\n\n```python\nimport json\nimport time\nimport requests\n\nAPI_URL = \"http://127.0.0.1:6781/mcp\"\nHEADERS = {\n    \"Authorization\": \"Bearer dev\",  # ggui serve --dev-allow-all; local dev only\n    \"Accept\": \"application/json, text/event-stream\",\n    \"Content-Type\": \"application/json\",\n}\n\nrequest_id = 0\n\ndef parse_sse(body: str) -> dict:\n    # Responses come back as a single SSE event; the JSON-RPC payload is\n    # the `data:` line of the `message` event.\n    for line in body.splitlines():\n        if line.startswith(\"data:\"):\n            return json.loads(line[len(\"data:\"):].strip())\n    raise RuntimeError(f\"no SSE data line in response: {body[:200]}\")\n\ndef mcp_request(method: str, params: dict | None = None) -> dict:\n    global request_id\n    request_id += 1\n    payload = {\"jsonrpc\": \"2.0\", \"id\": request_id, \"method\": method}\n    if params:\n        payload[\"params\"] = params\n    return parse_sse(requests.post(API_URL, headers=HEADERS, json=payload).text)\n\ndef call_tool(name: str, arguments: dict) -> dict:\n    result = mcp_request(\"tools/call\", {\"name\": name, \"arguments\": arguments})\n    if \"error\" in result:\n        raise RuntimeError(f\"MCP error: {result['error']}\")\n    return json.loads(result[\"result\"][\"content\"][0][\"text\"])\n\n# 1. Initialize the MCP session.\nmcp_request(\"initialize\", {\n    \"protocolVersion\": \"2025-11-25\",\n    \"clientInfo\": {\"name\": \"python-agent\", \"version\": \"1.0\"},\n    \"capabilities\": {},\n})\n# Notifications carry no `id` (JSON-RPC), so post this one directly.\nrequests.post(API_URL, headers=HEADERS, json={\n    \"jsonrpc\": \"2.0\", \"method\": \"notifications/initialized\",\n})\n\n# 2. Negotiate the contract.\nhandshake = call_tool(\"ggui_handshake\", {\n    \"intent\": \"Product feedback form\",\n    \"blueprintDraft\": {\n        \"contract\": {\n            \"propsSpec\": {\"properties\": {\n                \"rating\": {\"schema\": {\"type\": \"number\"}},\n                \"comments\": {\"schema\": {\"type\": \"string\"}},\n            }},\n            \"actionSpec\": {\"submit\": {\"label\": \"Submit feedback\", \"schema\": {\"type\": \"object\"}}},\n        },\n    },\n})\n\n# 3. Render — accept the handshake's suggestion as-is (omit `override`).\nresult = call_tool(\"ggui_render\", {\n    \"handshakeId\": handshake[\"handshakeId\"],\n    \"props\": {},\n})\nsession_id = result[\"sessionId\"]\nprint(f\"resource: {result['resourceUri']}\")\n\n# 4. Poll for events — keyed by sessionId.\nwhile True:\n    consume = call_tool(\"ggui_consume\", {\"sessionId\": session_id, \"timeout\": 20})\n    for entry in consume[\"events\"]:\n        # ConsumeEventEntry: {type: 'action', intent, actionData, uiContext, ...}\n        print(f\"intent={entry['intent']} data={entry['actionData']}\")\n    if consume[\"status\"] == \"expired\":\n        break\n    if consume[\"events\"]:\n        # Got the gesture we needed — exit on first non-empty payload.\n        break\n    time.sleep(2)\n\n# Render decays implicitly via TTL — no explicit close.\n```\n\nFor long-lived UIs, prefer the WebSocket channel (`ws://127.0.0.1:6781/ws` self-hosted; `wss://mcp.ggui.ai/ws` hosted) over polling — see [WebSocket Protocol](/api/websocket-protocol/).\n\n---\n\n## See also\n\n- [MCP Protocol Reference](/api/mcp-protocol/) — every method, every argument\n- [Claude Agent SDK example](/examples/claude-agent/) — higher-level loop that drives these same tools\n- [WebSocket Protocol](/api/websocket-protocol/) — push events instead of polling\n- [Envelopes](/protocol/envelopes/) — `ActionEnvelope`, `StreamEnvelope`\n- [Self-hosted quickstart](/quickstart/self-hosted/) — run your own `ggui serve` in minutes"},{"path":"/examples/openai-agent.md","title":"OpenAI Agent","description":"Bridge OpenAI function calling to GGUI's MCP server so GPT can show interactive UIs and read back structured user input.","body":"---\ntitle: OpenAI Agent\ndescription: Bridge OpenAI function calling to GGUI's MCP server so GPT can show interactive UIs and read back structured user input.\n---\n\n:::note[When you need this page]\nThis is the **Chat Completions** path: that API has no MCP client, so you bridge GGUI's MCP tools into OpenAI's function-calling shape yourself. If you are on the OpenAI **Agents SDK** (`@openai/agents`), skip the bridge — its `MCPServerStreamableHttp` connects to a GGUI endpoint directly. The runnable reference is [`samples/agents/openai-agents-sdk/`](https://github.com/ggui-ai/ggui/tree/main/samples/agents/openai-agents-sdk).\n:::\n\nOpenAI's Chat Completions API doesn't speak MCP, so you bridge GGUI's MCP tools into OpenAI's function-calling shape yourself. Two packages do the work:\n\n- **`@modelcontextprotocol/sdk`** — connects to your GGUI MCP endpoint (a local `ggui serve` below), enumerates GGUI's tools, executes tool calls.\n- **`openai`** — drives the GPT loop and decides when to call those tools.\n\nThe bridge is mechanical: list MCP tools once, map each to an OpenAI function definition, then forward every `tool_call` GPT emits straight through to `mcpClient.callTool`.\n\n## Setup\n\n```bash\nnpm install openai @modelcontextprotocol/sdk\n```\n\n```bash\nexport OPENAI_API_KEY=\"sk-...\"\n```\n\n:::note[Hosted GGUI]\nThe snippet below targets a local `ggui serve --dev-allow-all` (`http://127.0.0.1:6781/mcp`, `Authorization: Bearer dev` — local dev only; default `ggui serve` requires a paired bearer). Using hosted GGUI instead? Swap in `https://mcp.ggui.ai/apps/<appId>` and a `ggui_user_…` connector key from `ggui keys create`.\n:::\n\n## Code\n\n```typescript\n// openai-agent.ts\nimport OpenAI from \"openai\";\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\n\nconst openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });\n\n// 1. Connect to ggui's MCP server (`ggui serve --dev-allow-all` accepts any\n//    bearer — local dev only).\nconst mcpClient = new Client({ name: \"openai-ggui-agent\", version: \"0.1.0\" }, {});\nawait mcpClient.connect(\n  new StreamableHTTPClientTransport(new URL(\"http://127.0.0.1:6781/mcp\"), {\n    requestInit: {\n      headers: { Authorization: \"Bearer dev\" },\n    },\n  })\n);\n\n// 2. Enumerate ggui's MCP tools and bridge them into OpenAI's\n// function-calling tool shape. MCP `inputSchema` is already JSON Schema,\n// which is exactly what OpenAI's `parameters` expects.\nconst { tools: mcpTools } = await mcpClient.listTools();\nconst openaiTools: OpenAI.ChatCompletionTool[] = mcpTools.map((t) => ({\n  type: \"function\",\n  function: {\n    name: t.name,\n    description: t.description ?? \"\",\n    parameters: t.inputSchema as Record<string, unknown>,\n  },\n}));\n\nasync function chat(userPrompt: string) {\n  console.log(`\\nUser: ${userPrompt}`);\n\n  const messages: OpenAI.ChatCompletionMessageParam[] = [\n    {\n      role: \"system\",\n      content:\n        \"You are a helpful assistant. Use the available ggui tools to render interactive UIs whenever you need structured input from the user.\",\n    },\n    { role: \"user\", content: userPrompt },\n  ];\n\n  // 3. Drive the tool-call loop until GPT stops asking for tools.\n  while (true) {\n    const completion = await openai.chat.completions.create({\n      model: \"gpt-5.5\",\n      messages,\n      tools: openaiTools,\n    });\n\n    const message = completion.choices[0].message;\n    messages.push(message);\n\n    if (!message.tool_calls || message.tool_calls.length === 0) {\n      console.log(`\\nAssistant: ${message.content}`);\n      return;\n    }\n\n    // 4. Forward every tool call through MCP, feed results back to GPT.\n    //    `tool_calls` is a union since openai v6 — narrow to the function\n    //    variant before reading `call.function`.\n    for (const call of message.tool_calls) {\n      if (call.type !== \"function\") continue;\n      const result = await mcpClient.callTool({\n        name: call.function.name,\n        arguments: JSON.parse(call.function.arguments),\n      });\n\n      messages.push({\n        role: \"tool\",\n        tool_call_id: call.id,\n        content: JSON.stringify(result.content),\n      });\n    }\n  }\n}\n\nawait chat(\"Help me plan a team dinner for 8 people\");\nawait mcpClient.close();\n```\n\n## Run\n\n```bash\nnpx tsx openai-agent.ts\n```\n\nGPT calls `ggui_handshake` then `ggui_render`; the tool result carries `{sessionId, resourceUri}` — an MCP-Apps resource your host mounts. GPT then calls `ggui_consume({sessionId})`, and the loop delivers the submitted payload on the next turn.\n\n## How the bridge works\n\n- **MCP enumerates the toolset.** `listTools()` returns whatever GGUI exposes (`ggui_handshake`, `ggui_render`, `ggui_consume`, …); you don't hard-code a tool schema in your agent.\n- **MCP schemas are already OpenAI-compatible.** `tool.inputSchema` is JSON Schema; OpenAI's `function.parameters` is JSON Schema. The map is one-to-one.\n- **Tool dispatch is dumb forwarding.** Every `tool_call` GPT emits is passed verbatim to `mcpClient.callTool` — the MCP server (not your agent) owns render lifecycle, handshake, render, and consume.\n- **Identity vs grouping.** Identity comes from the `Authorization` header; conversation grouping (resume via `ggui_list_sessions`) comes from the optional `_meta[\"ai.ggui/host-session\"]` request slice. Reusing the same `Client` keeps the transport alive but does not itself group renders.\n\n## OpenAI-specific notes\n\n- Function arguments arrive as a JSON **string** — always `JSON.parse(toolCall.function.arguments)` before forwarding.\n- Tool results are returned as `{ role: \"tool\", tool_call_id, content }` messages (not Anthropic's `tool_result` block shape).\n- `result.content` from MCP is an array of content blocks; `JSON.stringify`-ing it gives GPT a faithful view of structured payloads. For text-only tools you can pull `result.content[0].text` instead.\n\n## Next\n\n- [`samples/agents/openai-agents-sdk/`](https://github.com/ggui-ai/ggui/tree/main/samples/agents/openai-agents-sdk) — the runnable OpenAI reference, on the Agents SDK's native MCP client instead of this bridge\n- [Claude Agent example](/examples/claude-agent/) — same outcome via the Claude Agent SDK's native MCP support (no manual bridging).\n- [Gemini Agent example](/examples/gemini-agent/) — the same MCP-bridge pattern adapted to Google's SDK.\n- [How it works](/how-it-works/) — the three channels (bootstrap, MCP, WebSocket) and envelope shapes."},{"path":"/glossary.md","title":"Glossary","description":"Lookup reference for GGUI terminology — GguiSession, contract, blueprint, channel, connector, gadget, tool, and the wire envelopes that tie them together.","body":"---\ntitle: Glossary\ndescription: Lookup reference for GGUI terminology — GguiSession, contract, blueprint, channel, connector, gadget, tool, and the wire envelopes that tie them together.\n---\n\nLookup reference for the terms that run through these docs. Each entry is short and links to where the term is documented in depth. For a narrative walkthrough, see [**How GGUI works**](/how-it-works/).\n\n## Identity and lifecycle\n\n### GguiSession (a \"render\")\n\nThe atomic unit of a GGUI exchange — the canonical protocol shape for a single rendered UI. A GguiSession is minted server-side inside `ggui_render` (one per UI emission), persists — reopenable via `resources/read` for as long as the deployment keeps it — and is keyed by a stable `sessionId` (opaque string). It is a union of Component / System / McpApps variants and carries a single component mount plus a stream of live-channel deliveries. The server is the authority on its state, not the agent. `render` survives as the verb and in wire constants (`ggui_render`, `ui://ggui/render`, the `ai.ggui/render` slice).\n\nConversation-scoped grouping (sibling GguiSessions inside the same host chat) flows through the unchanged `_meta[\"ai.ggui/host-session\"]` slice — captured ONCE at creation, never lifted onto every GguiSession. The agent does not thread a conversation id; instead each new `ggui_handshake` → `ggui_render` pair mints a fresh `sessionId`.\n\n→ See [MCP Protocol → ggui_handshake / ggui_render](/api/mcp-protocol/) for the lifecycle methods.\n\n### GguiSession contents\n\nA single GguiSession packages a compiled component plus its declared contract:\n\n- `sessionId` — the stable identifier\n- `componentCode` — the compiled JavaScript module returned by generation\n- `props` — the data payload the agent rendered\n- `propsSpec` / `actionSpec` / `streamSpec` / `contextSpec` — the four contract specs that pin the wire surface\n- `clientCapabilities` — the package-keyed gadget catalog projected from the contract at render time\n\nIn wire envelopes you'll see `sessionId` consistently — there is no separate conversation-session id layer.\n\n→ See [Envelopes → ActionEnvelope](/protocol/envelopes/#actionenvelope) for the wire shape.\n\n### App (`appId`)\n\nThe agent-builder's tenant scope. An `appId` (an opaque 8-character base62 string, e.g. `aB3kP9xY`) groups GguiSessions, blueprints, connectors, and provider keys under one boundary. On self-hosted `ggui serve`, the default app is whatever your `ggui.json` declares; on the hosted GGUI cloud, the `appId` is minted by the platform. Every `ggui_handshake` / `ggui_render` call is scoped to one `appId`.\n\n### `shortCode`\n\nAn internal 16-character token minted per render. Not on the agent wire — `ggui_render` returns `sessionId` + `resourceUri` (`ui://ggui/render/<id>`), and hosts mount the GguiSession from the `resourceUri`; the agent never receives a render URL — but `ggui serve` still uses shortCodes for its local render-viewer route (`/r/<shortCode>`).\n\n---\n\n## Wire\n\nGGUI's wire is split across orthogonal transports. Two share every framing: **MCP** (agent ↔ server request/response) and the **live channel** (server ↔ renderer WebSocket). A third depends on viewpoint — the [protocol overview](/protocol/overview/) names the **chat channel** (user ↔ wrapped agent, HTTP SSE) as the conversational-stack third; the [architecture overview](/architecture/overview/) names the **bootstrap channel** (server → renderer, one-shot bundle fetch) as the wire-pipeline third. The terms below all live on the live channel unless noted.\n\n### Audience\n\nA tag on every MCP handler — one of `agent`, `runtime`, `protocol`, or `ops` — that determines which of the server's two surfaces the tool appears on. The `/mcp` **data plane** serves the union of `agent` + `runtime` handlers (what the model and its runtime see during a session); the `/control` **control plane** serves `protocol` (design-time spec/discovery, anonymous) and `ops` (operator surface, authenticated and confirm-gated on mutations). The audience tag is set on every handler factory and encoded in the wire-name prefix (`ggui_protocol_*`, `ggui_ops_*`, `ggui_runtime_*`, or bare `ggui_*`).\n\n→ See [Audience routes](/architecture/audience-routes/).\n\n### Channel (live channel)\n\nA named outbound stream on the live-channel WebSocket (`ws://127.0.0.1:6781/ws` on `ggui serve`; `wss://mcp.ggui.ai/ws` on the hosted cloud). Agents declare channels in their `streamSpec`; the server validates each `StreamEnvelope` against the declared channel's schema before delivery. A channel has a name (e.g. `message`, `tasks`, `progress`), a payload schema, a state-folding `mode` (`'append'` or `'replace'`), and an optional `replay` policy.\n\nChannels prefixed with `_ggui:` are reserved for the server (e.g. `_ggui:preview`, `_ggui:lifecycle`). Agent-authored `streamSpec` cannot declare them.\n\n→ See [Envelopes → Reserved channels](/protocol/envelopes/#reserved-channels).\n\n### Contract\n\nThe typed agreement between agent and renderer for a given render: what the props look like, what actions the user can dispatch, what each action's payload schema is, what live channels the renderer subscribes to. Contracts are authored with `defineContract({…} as const)` so the protocol derives TypeScript types from a single source.\n\nA contract has four orthogonal specs:\n\n- **`propsSpec`** — initial render data (server → UI, one-time at render)\n- **`actionSpec`** — discrete events that drive the agent's next turn (UI → agent, via `ggui_consume`)\n- **`contextSpec`** — observable state mirrored from UI to server, last-write-wins (UI → server)\n- **`streamSpec`** — outbound deliveries from agent to UI (agent → UI, via `ggui_emit`)\n\nThe placement test for the two inbound specs: **does this thing need the agent's next-turn reasoning?** Yes → `actionSpec`. No → `contextSpec`. There is no third category — `actionSpec` carries events that drive turns; `contextSpec` carries state the agent observes when it next does work.\n\nWhen a delivery violates the contract, the server rejects it with a typed, named failure (a `CONTRACT_VIOLATION` error frame on the live channel; an `ok: false` `CONTRACT_VIOLATION` tool result on the relay, `ggui_runtime_submit_action`; a typed rejection on the agent's own tool call) — that's the contract's failure-mode surface. Nothing lands on the consume buffer. (The earlier `_ggui:contract-error` channel and `ContractErrorPayload` shape were removed in draft-2026-06-11.)\n\n### `ActionEnvelope`\n\nThe flat, narrow inbound envelope on the live channel. Carries a single canonical user action — `type` is always `'data:submit'` — plus a payload. The server validates `ActionEventValue` payloads against the render's `actionSpec`.\n\n→ See [Envelopes → ActionEnvelope](/protocol/envelopes/#actionenvelope).\n\n### `StreamEnvelope`\n\nThe outbound delivery on the live channel — one envelope per delivery on a named stream. Carries `channel`, `mode`, `payload`, and (when populated) a server-assigned `seq` for replay correctness.\n\n→ See [Envelopes → StreamEnvelope](/protocol/envelopes/#streamenvelope).\n\n### `nextStep` tool hint\n\nAn author-declared hint on a contract action: `actionSpec[name].nextStep` names an MCP tool — \"when the user dispatches this action, that tool is what should run next\". Every action is agent-routed: the server appends the action event to the GguiSession's consume buffer and the agent reacts on its next turn via `ggui_consume`. The hint rides along on `ActionEventValue.tool` (derived server-side at event-build time) so the agent sees the contract author's recommendation; the agent owns the call decision — the protocol never binds it.\n\nIn agent-less deployments (a bare `ggui serve` with no agent process attached), actions take the SAME path: each action event queues on the GguiSession's consume buffer until an agent attaches and drains it via `ggui_consume({sessionId})` — the server NEVER invokes a tool on the user's behalf. There is no second routing model.\n\nActions without a `nextStep` are pure event signals — the agent receives `{action, data}` and decides what to do unconstrained.\n\n(The retired `dispatch.kind === 'tool' | 'agent'` discriminated union from earlier drafts is gone; outside material still using that vocabulary is stale.)\n\n### `ggui_emit`\n\nThe MCP tool the agent calls to emit a delivery onto a declared `streamSpec` channel of an existing render. Input is `{sessionId, channel, payload}`; the server validates `payload` against `streamSpec[channel].schema` and fans it out as a `StreamEnvelope` on the live channel. The outbound counterpart to `ggui_consume`'s inbound drain.\n\n→ See [MCP Protocol → `ggui_emit`](/api/mcp-protocol/#ggui_emit).\n\n### `ggui_consume`\n\nThe MCP tool the agent long-polls to drain buffered user events off one render. Consume-once semantics — events drain on read. Call this right after every `ggui_render` whose response carries `nextStep.tool === 'ggui_consume'` (i.e. the rendered contract has a non-empty `actionSpec`). Drained events surface with optional `tool` metadata mirrored from `actionSpec[action].nextStep`.\n\n→ See [MCP Protocol → `ggui_consume`](/api/mcp-protocol/#ggui_consume).\n\n### `nextStep` (advisory recovery hint)\n\nA small wire-shape object the server returns on `ggui_handshake` and `ggui_render`. The handshake form is `{tool: 'ggui_render', example}`; the render form is `{tool: 'ggui_consume', description, example, args: {sessionId}}` — `args` carries the literal value to pass to `ggui_consume`. The `example` is a literal worked call prefilled with current ids, so the agent can recover the next step without re-reading the docs. On `ggui_render`, `nextStep` is emitted ONLY when the rendered contract has a non-empty `actionSpec` and points at `ggui_consume`; pure-display renders get no `nextStep`.\n\nDistinct from `actionSpec[name].nextStep`, which is an author-declared **tool hint** on a contract action (see [`nextStep` tool hint](#nextstep-tool-hint)).\n\n### MCP service\n\nA self-contained MCP server mounted at its own HTTP path (e.g. `/docs`, `/playground/todos`) with its own tool catalog, auth adapter, and lifecycle. Distinct from an `McpServerMount`, which aggregates tools onto the audience-filtered shared surfaces (`/mcp`, `/control`); a service stands alone at its mount path. Used when a tool group needs route-level isolation — a different auth posture, a tighter tool catalog, or a public read-only surface.\n\n→ See [MCP services](/architecture/mcp-services/).\n\n---\n\n## Capabilities\n\nGGUI has two symmetric **capability surfaces** — one for what the _agent_ can do, one for what the _renderer_ can render. Both are declared per-app and bounded by the operator, not the agent. They mirror each other across the wire: the agent uses its tools to drive a render; the renderer uses its gadgets to render the result.\n\n|           | Renderer (renders UI)               | Agent (drives render)     |\n| --------- | ----------------------------------- | ------------------------- |\n| Unit      | **gadget**                          | **tool**                  |\n| Catalog   | `clientCapabilities.gadgets`        | `agentCapabilities.tools` |\n| Lifecycle | Loaded at iframe boot, SRI-verified | Bound at render start     |\n| Authoring | `ggui.gadget.json` manifest         | MCP tool code             |\n\n### Gadget (renderer-side capability)\n\nA self-contained bundle the UI generator picks up when assembling a UI. Concrete examples: a Leaflet gadget exports a `LeafletMap` component the generated UI renders; a Stripe gadget exposes a checkout flow; a Calendar gadget exposes date-picker behaviour. Gadgets bridge third-party JS into the GGUI iframe runtime via the `createGguiGadget` factory; a contract references them through the package-keyed `clientCapabilities.gadgets` map (outer key = npm package, inner key = export name), and they load SRI-verified at boot. Authored as `ggui.gadget.json` manifests and published to a marketplace registry (resolved via the shared chain `--registry` > `GGUI_REGISTRY` > `ggui.json#registry`; read verbs — install/search — fall back to `registry.ggui.ai`).\n\nThe term replaced the older `clientLibraries` / \"Client Libraries\" name; old external references may still use it.\n\n### Tool (agent-side capability)\n\nAn MCP tool the agent has access to when orchestrating renders — `ggui_handshake`, `ggui_render`, `ggui_consume`, plus any custom tools the operator wires in. Tools are the agent-side counterpart of gadgets: gadgets give the _renderer_ something to render with; tools give the _agent_ something to act with. Declared per-app in `agentCapabilities.tools` and surfaced to the model via the MCP server.\n\nBlueprints sit in a different category — they're not capabilities, they're _cached responses_ (pre-composed UIs) that short-circuit fresh generation. See [Blueprint](#blueprint) below.\n\n### Ops tool\n\nAn MCP tool tagged with `audience: ['ops']`, surfaced on the server's `/control` plane (e.g. `http://127.0.0.1:6781/control`). Every action the console UI can perform is also available as an ops tool, so an LLM operator agent can drive the same workflows programmatically — list apps, rotate keys, inspect renders, yank blueprints. Ops tools require authentication (unlike the anonymous `protocol` tools sharing the route), and state-changing ones require a second call with `confirm: true`. Wire-name prefix is `ggui_ops_*`, distinguishing them from agent-facing `ggui_*` tools.\n\n→ See [Control MCP](/api/control-mcp/).\n\n### Theme\n\nA per-app visual overlay. `AppTheme = {mode: 'light' | 'dark', cssVariables, name?}` — where `cssVariables` is a map of `--ggui-*` custom properties — is snapshotted onto each GguiSession at render-commit and applied at the iframe `:root` after the base design tokens, so operator values win the cascade. Presets ship in `@ggui-ai/design` (7 today); agents discover them via `ggui_list_themes` and override per render with `ggui_render({themeId})`. Resolution chain: `GguiSession.themeId` → `App.defaultThemeId` → server fallback.\n\n---\n\n## Generation\n\n### Blueprint\n\nA cached UI primitive — a stable, reusable card whose code, contract, and prompt are pre-computed. Blueprints are matched during `ggui_handshake` by the semantic cache for generative UI (below), on intent + contract similarity (semantic search + LLM rerank); the paired `ggui_render` then serves the cached component — a hit serves the pre-built component with zero generation calls; a miss runs fresh generation — both render in seconds. Project-local blueprints are authored as `ggui.ui.json` manifests and declared via `ggui.json#blueprints.include` globs; for marketplace publishing, a blueprint repo carries a `ggui.blueprint.json` artifact manifest (`ggui blueprint publish`).\n\nWhere **gadgets** are ingredients the LLM composes with and **tools** are actions the LLM invokes, **blueprints** are recipes the LLM can return directly — a cache short-circuit for already-solved screens.\n\n### Semantic cache for generative UI\n\nGGUI's reuse layer: the blueprint match that runs at `ggui_handshake`, before any generation. An exact contract-key lookup comes first. When it misses, candidates from the app's own pool are retrieved by embedding similarity, and a judge model picks one or none. A hit (`suggestion.origin: 'cache'`) serves a stored blueprint with zero generation calls; a miss falls through to a cold generation. See [Blueprints and the cache](/concepts/blueprints-and-the-cache/).\n\n### Primitive\n\nA leaf-level UI component declared in a primitive catalog (e.g. Button, Input, SearchField). Primitives are catalog-declared, not protocol-declared — the agent doesn't ship a fixed component set; the operator declares which primitives are available via `ggui.json#primitives`.\n\nComponent levels nest: **primitive** (Button) → **component** (SearchField) → **composite** (LoginForm, Modal) → **template** (ListDetail, Dashboard page).\n\n### `shellType`\n\nThe visual shell the renderer wraps a render in. Three values today: `chat` (conversation pane), `fullscreen` (whole-window app), `spatial` (XR / immersive surface). Carried on `interfaceContext.shellType` and read by the runtime when mounting a render.\n\n---\n\n## Auth\n\n### Anonymous service\n\nAn `McpService` mounted with `anonymous: true`. Auth becomes OPTIONAL, not skipped: a presented valid bearer still resolves to the caller's real identity; only a missing or invalid credential falls back to a synthesized `{identity: {kind: 'builder'}, source: 'anonymous'}` principal. Safe for read-only public surfaces (e.g. the docs MCP service that serves how-to lookups); tools that mutate or read tenant-scoped data must re-impose auth at the handler level.\n\n→ See [MCP services](/architecture/mcp-services/).\n\n### Connector / connector key\n\nA `ggui_user_*` API key that authenticates an agent runtime against a GGUI MCP server. Locally, `ggui keys create --keys-file <path>` mints bearers that `ggui serve --keys-file` accepts — no account needed. On the hosted cloud's universal endpoint, keys are minted via [`ggui keys create`](/cli/login/#manage-keys) after [`ggui login`](/cli/login/), or straight from [the GGUI console](https://console.ggui.ai). Connector keys are scoped to one user; revoking via `ggui keys revoke <id>` is a soft-revoke (the audit row stays, but every subsequent request from the key returns `401 invalid_grant`).\n\nDistinct from the **bearer** minted by `ggui serve`'s pairing flow — that bearer is per-session and per-pairing, not user-keyed.\n\n### `wsToken`\n\nA short-TTL opaque credential minted at `ggui_render` and delivered to the iframe on `_meta[\"ai.ggui/render\"]`, paired with `wsUrl`. The iframe presents it on the WebSocket upgrade (`?wsToken=`) and in `SubscribePayload.wsToken`; the server validates it against the subscribing `sessionId` + `appId`. Refreshed without a re-handshake via the runtime-audience tool `ggui_runtime_refresh_ws_token`.\n\n### `AuthAdapter`\n\nThe pluggable seam in `@ggui-ai/mcp-server` that decides who's authenticated on `/mcp` and the live-channel `/ws` upgrade. The default for `ggui serve` is dev-mode pairing; production composes `createGguiServer({ auth })` with a custom adapter (OIDC, Cognito, custom).\n\n→ See [`ggui serve` → Production hardening](/cli/serve/#production-hardening).\n\n---\n\n## See also\n\n- [Protocol overview](/protocol/overview/) — the three-channel topology these terms hang off.\n- [Envelopes](/protocol/envelopes/) — wire reference for `ActionEnvelope` and `StreamEnvelope`.\n- [MCP Protocol](/api/mcp-protocol/) — MCP method reference (`ggui_handshake`, `ggui_render`, `ggui_update`, `ggui_amend`, `ggui_consume`).\n- [WebSocket Protocol](/api/websocket-protocol/) — live-channel message-type reference."},{"path":"/host-helpers/react-native.md","title":"React Native host helpers (MCP Apps)","description":"@ggui-ai/mcp-apps-react-native — host-helper library for embedding GGUI views in a React Native or Expo app via <McpAppIframe>, the WebView-backed MCP Apps host. Not an SDK: rendering semantics live in the mounted view.","body":"---\ntitle: React Native host helpers (MCP Apps)\ndescription: @ggui-ai/mcp-apps-react-native — host-helper library for embedding GGUI views in a React Native or Expo app via <McpAppIframe>, the WebView-backed MCP Apps host. Not an SDK: rendering semantics live in the mounted view.\n---\n\n:::tip[React Native host: `<McpAppIframe>`]\nOn React Native the host primitive is `<McpAppIframe>` — a `react-native-webview` WebView that speaks the MCP Apps host protocol. It is RN-only; there is no `<McpAppIframe>` on web, where the host is `<AppRenderer>` from `@mcp-ui/client` (see [React host helpers](/host-helpers/react/)).\n\nThe web migration to `<AppRenderer>` relied on a two-iframe cross-origin sandbox proxy. A WebView is itself a top-level browser surface, not a nestable origin, so that architecture has no RN equivalent — which is why `<McpAppIframe>` remains the canonical host here.\n:::\n\n`@ggui-ai/mcp-apps-react-native` is a **host-helper library, not an SDK**. It gives your app the host half of the MCP Apps contract — mount a UI resource, answer `ui/initialize`, relay `tools/call`, open links, tear down. Rendering semantics (freeze latch, history epochs, error surfaces) live inside the mounted view, not in your tree.\n\n## Installation\n\n```bash\nnpm install @ggui-ai/mcp-apps-react-native@0.28.0\n```\n\nPeer dependencies, installed by your app: `react` (18 or 19), `react-native` (>= 0.70), `react-native-webview` (>= 13), and `@modelcontextprotocol/sdk`.\n\n| Import path                                   | Contents                                                               |\n| --------------------------------------------- | ---------------------------------------------------------------------- |\n| `@ggui-ai/mcp-apps-react-native`              | `<McpAppIframe>`, `<GguiProvider>`, `useInvoke`, `<UiFeedback>`, theme |\n| `@ggui-ai/mcp-apps-react-native/chat-helpers` | Message-grouping + render-extraction helpers                           |\n\n---\n\n## Mounting a render — `<McpAppIframe>`\n\nGive the component the UI resource from a tool result plus the render metadata carried on `_meta[\"ai.ggui/render\"]`. It mounts the resource in a WebView and runs the host side of the handshake:\n\n```tsx\nimport { useRef } from \"react\";\nimport { McpAppIframe } from \"@ggui-ai/mcp-apps-react-native\";\nimport type { McpAppIframeProps, McpAppIframeRef } from \"@ggui-ai/mcp-apps-react-native\";\n\nexport function AgentCard({\n  resource,\n  meta,\n  callTool,\n}: {\n  resource: McpAppIframeProps[\"resource\"];\n  meta: McpAppIframeProps[\"meta\"];\n  callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;\n}) {\n  const ref = useRef<McpAppIframeRef>(null);\n\n  return (\n    <McpAppIframe\n      ref={ref}\n      resource={resource}\n      meta={meta}\n      containerDimensions={{ width: 360, maxHeight: 640 }}\n      onToolCall={callTool}\n      onError={(err) => console.warn(\"render error\", err.kind)}\n      onObserve={(event) => console.log(\"observe\", event.kind)}\n      onLifecycle={(event) => console.log(\"lifecycle\", event.state)}\n    />\n  );\n}\n```\n\n### Props\n\n| Prop                   | Type                                                      | Notes                                                                                                               |\n| ---------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |\n| `resource`             | `ResourceContents`                                        | Required. `text` mounts as inline HTML, `blob` as a data URL, otherwise the `uri` is loaded (`http(s)` only).       |\n| `meta`                 | `McpAppAiGguiRenderMeta`                                  | First-party GGUI renders only. Delivered as a `ui/notifications/tool-result` right after the initialize response.   |\n| `onToolCall`           | `(toolName, args) => Promise<unknown>`                    | Relay for `tools/call`. Without it every call is rejected `no-tool-handler`.                                        |\n| `onUpdateModelContext` | `(params) => Promise<void> \\| void`                       | Receives `ui/update-model-context`. Without it the host answers `method_not_supported`.                             |\n| `containerDimensions`  | `{ width?, height?, maxWidth?, maxHeight? }`              | Styles the outer `<View>` and is echoed on `ui/initialize.result.hostContext`.                                      |\n| `permissions`          | `{ camera?, microphone?, geolocation?, clipboardWrite? }` | Platform WebView gating. Never reaches `ui/initialize`.                                                             |\n| `locale`               | `string`                                                  | Forwarded as `hostContext.locale`. Defaults to `en-US` — RN has no `navigator`.                                     |\n| `onError`              | `(err: ProtocolError) => void`                            | Classified failures, including the `ggui:bootstrap-failed` envelope. Handlers must not throw.                       |\n| `onObserve`            | `(event: ObservabilityEvent) => void`                     | The `ggui:observe` channel. Tolerate unknown `event.kind`.                                                          |\n| `onLifecycle`          | `(event: McpAppLifecycleEvent) => void`                   | Also mirrored on the outer `<View>` as `accessibilityValue={{ text: state }}` whether or not you bind the callback. |\n\nThe imperative ref exposes one method, `dispatchAction(name, data)`, which posts a fire-and-forget JSON-RPC notification into the WebView.\n\n`ui/open-link` with an `http(s)` URL is delegated to `Linking.openURL`; other schemes are rejected `unsupported-scheme`. Set `meta` only for WebViews you spawned from GGUI's own render resource URI — passing it to third-party MCP App content leaks outer-app state across the adapter boundary.\n\n---\n\n## Relationship to the web package\n\nThe two packages are twins, and a parity test enforces it. `chat-helpers/message-groups.ts`, `chat-helpers/render.ts`, and `chat-helpers/useRafThrottled.ts` are byte-identical across both packages, and `components/GguiProvider.tsx` and `components/UiFeedback.tsx` are documented platform-delta twins whose exported surfaces must match. A one-sided edit fails both suites.\n\nTwo deliberate differences:\n\n- **`useMcpAppsChat` is web-only.** RN's `chat-helpers` ships the platform-neutral subset — `invokeMessageToContentGroups`, `contentGroupsToConversationMessages`, `conversationMessagesToInvokeHistory`, `extractRenderFromToolResult`, `extractSessionIdFromToolResult`, `useRafThrottled`. Drive the stream with `useInvoke` (mounted inside `<GguiProvider>`) and own your message history — the pattern is the [chat-own-storage cookbook](/cookbook/chat-own-storage/).\n- **`<McpAppIframe>` is RN-only**, for the sandbox-proxy reason above.\n\nEverything else lines up: the same `ProtocolError` union, the same `ObservabilityEvent` catalog, the same protocol types, and a React Native theme system (`ThemeProvider`, `useTheme`) mirroring the web design tokens.\n\n## What was removed\n\nThe legacy in-tree render family — `GguiRender`, `DynamicComponent`, `WebViewRenderer`, `NativeRegistry`, and their WebSocket stack — was deleted from this package. Generated component code runs inside the WebView, not in your React Native tree; there is no in-process renderer to import."},{"path":"/host-helpers/react.md","title":"React host helpers (MCP Apps)","description":"@ggui-ai/mcp-apps-react — host-helper library for embedding GGUI views in React via <AppRenderer> (@mcp-ui/client) + the useMcpAppsChat hook. Not an SDK: rendering semantics live in the mounted view.","body":"---\ntitle: React host helpers (MCP Apps)\ndescription: @ggui-ai/mcp-apps-react — host-helper library for embedding GGUI views in React via <AppRenderer> (@mcp-ui/client) + the useMcpAppsChat hook. Not an SDK: rendering semantics live in the mounted view.\n---\n\n:::tip[Web host: `<AppRenderer>` + `useMcpAppsChat`]\nThe web consumer surface is `<AppRenderer>` — imported **directly from `@mcp-ui/client`** (the spec-canonical MCP Apps host; GGUI does not re-export it) — driven by GGUI's `useMcpAppsChat` hook. `<AppRenderer>` mounts each GGUI render in a sandboxed iframe; the iframe owns the WebSocket lifecycle + renderer bundle, so your host code never imports render internals or WebSocket machinery.\n\nReact Native's equivalent host is `<McpAppIframe>` (from `@ggui-ai/mcp-apps-react-native`) — RN-only; there is no `<McpAppIframe>` on web. The bootstrap envelope, postMessage events, and `BootstrapFailureReason` codes are spec'd in [Bootstrap handshake](/protocol/bootstrap-handshake/).\n:::\n\n`@ggui-ai/mcp-apps-react` lets you embed ggui-generated UIs inside a React web app. It has two layers, used independently:\n\n1. **Render hosting** — `useMcpAppsChat` + `<AppRenderer>`. The canonical path: drive an MCP-Apps agent backend and mount its renders. **Start here.**\n2. **Invoke stream helpers** — `useInvoke` + `chat-helpers`. The low-level invoke-protocol stream and content-group utilities, for apps that own their message history (see the [own-storage cookbook](/cookbook/chat-own-storage/)).\n\nTwo in-process render primitives (`<GguiSessionRenderer>` / `<DynamicComponent>`) also ship, retained solely for the `@ggui-ai/console` debugger and dev-stack preview tooling — see [Legacy primitives](#legacy-primitives-console-debugger-only). New consumer code never needs them: rendering semantics (freeze latch, history epochs, error surfaces) live in the mounted view itself, which is why an embedded render behaves exactly like claude.ai.\n\n## Installation\n\n```bash\nnpm install @ggui-ai/mcp-apps-react @mcp-ui/client\n```\n\n`react` / `react-dom` (18 or 19) and `@modelcontextprotocol/sdk` are peer dependencies. `@mcp-ui/client` is a direct dependency you import `<AppRenderer>` from.\n\n| Import path                            | Contents                                                                                           |\n| -------------------------------------- | -------------------------------------------------------------------------------------------------- |\n| `@ggui-ai/mcp-apps-react`              | Providers, `useInvoke`, `<UiFeedback>`, tool-result helpers, error types, legacy render primitives |\n| `@ggui-ai/mcp-apps-react/chat-helpers` | `useMcpAppsChat` + render/message helpers                                                          |\n\n---\n\n## Render hosting — `useMcpAppsChat` + `<AppRenderer>`\n\nAn agent emits UI as MCP-Apps renders. You drive the conversation with `useMcpAppsChat` and mount each render's sandboxed iframe with `<AppRenderer>` (imported directly from `@mcp-ui/client`):\n\n```tsx\nimport { AppRenderer } from \"@mcp-ui/client\";\nimport { useMcpAppsChat } from \"@ggui-ai/mcp-apps-react/chat-helpers\";\n\nfunction Chat({ agentUrl, sandboxUrl }: { agentUrl: string; sandboxUrl: string }) {\n  const { entries, sessions, send, handleAppMessage } = useMcpAppsChat({\n    chatEndpoint: `${agentUrl}/agent`,\n    snapshotEndpoint: `${agentUrl}/agent`,\n  });\n\n  // Relay callbacks — minimal versions of the wiring in ggui-basic-web:\n  const callViaRelay = async ({\n    name,\n    arguments: args,\n  }: {\n    name: string;\n    arguments?: Record<string, unknown>;\n  }) => {\n    const r = await fetch(`${agentUrl}/agent`, {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ kind: \"tool-call\", name, arguments: args ?? {} }),\n    });\n    const { result } = await r.json();\n    return result; // CallToolResult — see ggui-basic-web for auth + the error envelope\n  };\n  const readViaRelay = async ({ uri }: { uri: string }) => {\n    // Never fires when `html` is supplied — the agent-server interceptor inlines\n    // the iframe HTML. Add a real relay only for guest re-reads.\n    throw new Error(`unexpected resources/read for ${uri}`);\n  };\n\n  // render `entries` as chat bubbles; call send(prompt) to talk to the agent\n  const latest = sessions[sessions.length - 1];\n  return latest ? (\n    <AppRenderer\n      toolName=\"ggui_render\"\n      sandbox={{ url: new URL(sandboxUrl) }}\n      html={latest.inlinedResource?.text}\n      onReadResource={readViaRelay}\n      onCallTool={callViaRelay}\n      onMessage={handleAppMessage}\n      onError={(err) => console.warn(\"render error\", err)}\n    />\n  ) : null;\n}\n```\n\n`<AppRenderer>`'s sandbox + resource-read + tool-call relay wiring is non-trivial (it implements the MCP-Apps host contract: a second-origin sandbox proxy, plus `onReadResource` / `onCallTool` callbacks that relay through your agent backend). The complete runnable reference — including auth and the relay — is the [`ggui-basic-web`](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) sample. **Start there.**\n\n### `useMcpAppsChat(options)`\n\n| Option              | Type                                      | Required | Description                                                                                                      |\n| ------------------- | ----------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |\n| `chatEndpoint`      | `string`                                  | Yes      | POST endpoint; the hook opens an SSE stream and feeds each `event: message` frame.                               |\n| `snapshotEndpoint`  | `string`                                  | No       | GET endpoint for the server-authoritative snapshot (rehydration). Defaults to `chatEndpoint`.                    |\n| `chatId`            | `string`                                  | No       | Stable conversation id. Omit for a fresh chat — the server allocates one.                                        |\n| `onChatAllocated`   | `(chatId: string) => void`                | No       | Fires when the server mints a fresh id; stamp it into URL / localStorage for rehydration.                        |\n| `getAuthToken`      | `() => string \\| undefined \\| Promise<…>` | No       | Bearer token sent as `Authorization: Bearer <token>` per request. See [Auth-Gated UI](/cookbook/auth-gated-ui/). |\n| `onUnauthenticated` | `() => boolean \\| Promise<boolean>`       | No       | `401` handler — refresh the token, return `true` to retry once.                                                  |\n\n**Returns** `{ entries, sessions, hostDisplayMode, sending, send, handleAppMessage, abort }`:\n\n| Field              | Type                                                  | Description                                                                                                                                        |\n| ------------------ | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `entries`          | `ReadonlyArray<ChatEntry>`                            | Render-ready chat log (`user` / `assistant` / `tool-call` / `session` / `error` / `end`). A `session` entry carries `{ session: GguiSessionRef }`. |\n| `sessions`         | `ReadonlyArray<GguiSessionRef>`                       | Every MCP-Apps resource produced this conversation (latest last). Mount with `<AppRenderer>`.                                                      |\n| `hostDisplayMode`  | `'inline' \\| 'fullscreen' \\| 'pip' \\| undefined`      | Most-recent render's `_meta.ui.displayMode` hint (MCP-Apps SEP-1865) for layout auto-switch.                                                       |\n| `sending`          | `boolean`                                             | True between `send()` and turn completion.                                                                                                         |\n| `send`             | `(prompt: string, opts?: { meta? }) => Promise<void>` | Post a user prompt; opens the SSE stream.                                                                                                          |\n| `handleAppMessage` | `(params) => Promise<Record<string, unknown>>`        | Drop-in for `<AppRenderer onMessage>` — forwards a guest `ui/message` to the agent.                                                                |\n| `abort`            | `() => void`                                          | Abort the in-flight stream.                                                                                                                        |\n\nA `GguiSessionRef` carries `{ resourceUri, action, toolUseId?, inlinedResource? }`. When `inlinedResource.text` is present (the `@ggui-ai/agent-server` interceptor pre-fetched the iframe HTML), pass it as `<AppRenderer html={…}>` and skip the `onReadResource` round-trip.\n\n---\n\n## Chat persistence — bring your own storage\n\nThe package deliberately ships NO chat-persistence framework. Own your message history with your own storage and drive the stream with `useInvoke` + the `chat-helpers` conversion utilities — the full pattern is the [Chat with your own storage](/cookbook/chat-own-storage/) cookbook.\n\n### `useInvoke` — the low-level chat stream\n\n`useInvoke` is the low-level stream primitive. It POSTs the user's message + history to `{endpointUrl}/invoke`, reads the SSE response, and accumulates assistant content blocks. It reads its endpoint + app id from the nearest `<GguiProvider>`, so it must be mounted inside one.\n\n```tsx\nimport { GguiProvider, useInvoke } from \"@ggui-ai/mcp-apps-react\";\n\nfunction Chat() {\n  const { messages, send, isStreaming, error } = useInvoke({ endpointUrl: \"/api/chat\" });\n  // messages: ConversationMessage[] — { id, role, content: ContentBlock[], isStreaming }\n}\n\n// …mounted inside <GguiProvider appId=\"<yourAppId>\">…</GguiProvider>\n```\n\n`send(text, { clientMessageId })` lets the caller control the user message id — the basis for retry-without-duplicates and cross-device continuity. The `@ggui-ai/mcp-apps-react/chat-helpers` exports (`invokeMessageToContentGroups`, `contentGroupsToConversationMessages`, `useRafThrottled`, `extractRenderFromToolResult`) turn the stream into a durable persistence shape — see the [chat-own-storage cookbook](/cookbook/chat-own-storage/).\n\n---\n\n## `<UiFeedback>` — host-side feedback affordance\n\n`<UiFeedback>` (from the package root) renders a thumbs-up/down affordance for the currently mounted render. It stays hidden entirely unless you wire its `onUiFeedback` callback — with no callback wired, it renders nothing.\n\nAn in-iframe twin of the same affordance lives in `@ggui-ai/iframe-runtime` and emits a `ui-feedback` event on the `ggui:observe` seam instead — it is **off by default**: the serving runtime must opt in via its boot options (`uiFeedback: true`). Wire **exactly one** of the two surfaces — the host-side `<UiFeedback onUiFeedback>` or the in-iframe twin — never both, or feedback submits twice.\n\nTwo tool-result helpers round out the root export: `buildAppRendererToolResult` builds the `<AppRenderer>` toolResult envelope from a raw MCP tool result, and `extractUiMoments` pulls `UiMoment` entries (feedback, interaction milestones) out of an invoke stream.\n\n---\n\n## Theming\n\n`@ggui-ai/design`'s `<ThemeProvider>` themes your host chrome by injecting `--ggui-*` CSS variables; the agent's `ggui.json` `theme` preset themes the generated iframe content. The operator's per-app theme overlay (`ggui.json#theme`) is delivered on the render's bootstrap meta and applied inside the iframe automatically — your host code never injects variables into generated content. See [Custom Theming](/cookbook/custom-theming/) for the two-layer model and the full token reference.\n\n---\n\n## Legacy primitives (console-debugger only)\n\nBefore the MCP-Apps `<AppRenderer>` host, GGUI rendered generated component code **directly in the React tree**. Those primitives still ship and are still exported — `@ggui-ai/console`'s render debugger imports them for in-process render visibility — but they are **not the consumer path**. New web code hosts renders with `<AppRenderer>` (above); generated component code runs inside the sandboxed iframe, not in your tree.\n\n| Export                  | Was                                                                  |\n| ----------------------- | -------------------------------------------------------------------- |\n| `<GguiProvider>`        | App-config context. Still required as the ancestor for `useInvoke`.  |\n| `<GguiSessionRenderer>` | Renders a `GguiSession`'s compiled component code in-tree.           |\n| `<DynamicComponent>`    | Imports + mounts compiled ESM component code at runtime.             |\n| `<ProvisionalRenderer>` | Paints in-flight `_ggui:preview` envelopes while generation streams. |\n\nThese read against the live-channel WebSocket directly (`ws://127.0.0.1:6781/ws` for `ggui serve`). For their exact prop shapes, read the source in [`@ggui-ai/mcp-apps-react`](https://github.com/ggui-ai/ggui/tree/main/packages/mcp-apps-react) — they're documented as implementation, not a stable consumer contract. There is no `useWebSocket` export — the package doesn't expose a low-level WS client of its own. The only remaining low-level WS client is `WSTransport` from [`@ggui-ai/live-channel`](https://www.npmjs.com/package/@ggui-ai/live-channel) (reconnect ladder: 1s → cap 60s, 10 attempts, never-opened fail-fast), reached in practice through `@ggui-ai/iframe-runtime`."},{"path":"/hosted/billing.md","title":"Credits, billing & BYOK","description":"How hosted GGUI charges for renders — one product, pay-as-you-go from a prepaid credit balance at published rates per render kind, with no subscription of any kind — plus the welcome credit, top-ups, coupons, and what changes when you bring your own provider key.","body":"---\ntitle: Credits, billing & BYOK\ndescription: How hosted GGUI charges for renders — one product, pay-as-you-go from a prepaid credit balance at published rates per render kind, with no subscription of any kind — plus the welcome credit, top-ups, coupons, and what changes when you bring your own provider key.\n---\n\nHosted GGUI is **one product, paid as you go**: every render is charged to a prepaid credit balance at a published rate per render kind, and a one-time welcome credit on sign-up, while it is available, is the free entry — no card, no plan to pick. The rates, the welcome credit and the top-up presets live in one place, [ggui.ai/pricing](https://ggui.ai/pricing); this page is the mechanics.\n\nEverything on this page lives at `/credits`, `/keys/providers`, and each app's **Billing** tab in [the GGUI console](/hosted/console/).\n\n## One product, prepaid credit\n\nThere are no plans to choose between and nothing is metered monthly. A render costs its per-kind rate, debited from the **app owner's** prepaid balance at render time, and a balance at or below zero refuses the next render — on your own provider key too, since the platform lane is still charged — until you top up. Published rates, no subscription of any kind: there are no usage bands, and nothing is withheld from a small account. **Enterprise** is a conversation, not a price: negotiated rates, invoicing and terms through [enterprise@ggui.ai](mailto:enterprise@ggui.ai).\n\n**The free entry.** A new account receives a one-time welcome credit on first sign-in while it is available, with no card, and renders on it until it is spent — the same rates as purchased credit, the same product. It does not expire: there is no separate trial state, no render count to run out of and no day clock, and when the welcome credit is gone a top-up continues exactly where you were.\n\n## What a render costs\n\nEach render is priced by its kind — a **cold generation** (the LLM synthesized a component), a **cache hit** (the render matched an existing blueprint and skipped generation; cheaper, but not free — see the [generation pipeline](/architecture/overview/#generation-pipeline)), or a **BYOK render** (your own provider key paid for the model call, so GGUI charges only its platform lane). The three rates are on [ggui.ai/pricing](https://ggui.ai/pricing). The rates apply unless a model has its own rate set for your account, as when a premium model is enabled. Larger models are enabled per account on request, not by default, and charged at a per-model rate set for your account, in place of the render rate; that page does not list those rates (see [Premium models](#premium-models)). A pool-funded render that asks for a model other than the pool default, with no row, is refused with `model_not_allowed` rather than silently re-priced.\n\nCharges accrue per render and are debited to the app owner's balance in whole cents, carrying sub-cent remainders forward — a render is never rounded up on its own. An app may also carry an optional hard monthly spending cap, set on request: write to [hello@ggui.ai](mailto:hello@ggui.ai) with the app and the monthly amount, and we set, change or remove it for you. Once the app's render spend for the month reaches the cap, generation is refused (`hard_cap_exceeded`) until the cap is raised or the month resets.\n\nPool-funded renders draw on GGUI's own platform pool, which covers Anthropic (direct or routed through Bedrock), OpenAI, Google, and OpenRouter. A render routed to any provider outside that set is refused with a `402` pointing you at BYOK.\n\n### Premium models\n\nOn a pool-funded render the model is resolved from the app's generation setting, then the request's `infra.model`, then the pool default. Any model other than the pool default must have its own **rate-card row for your account** — one row per model, carrying the three lanes above (cold generation, cache hit, BYOK platform lane). No premium model is enabled by default: every account starts with the pool model only, and a row is seeded for your account on request. A model with no row is refused before anything is read or spent, with `model_not_allowed` and a fix in the message: send the pool default (or omit `infra.model`), bring your own key for that model, or ask [hello@ggui.ai](mailto:hello@ggui.ai) to enable it. The gate does not apply to BYOK renders — your key, your model — or to guuey-managed apps, which are metered to the platform under their own policy.\n\nThe rows seeded for your account are listed in the console: the **Premium models** card on the app's Billing tab (`console.ggui.ai/apps/<appId>/billing`) shows one row per enabled model route, with the cold-generation, cache-hit and BYOK-lane rate per render — the same rows the gate prices from. A render on a listed model bills its row's rate in place of the standard rate above. When no premium model is enabled the card says so; the pool model is always available, and to run another model on the app you bring your own key for it or write to [hello@ggui.ai](mailto:hello@ggui.ai). The Settings model picker is a different thing: it lists the model registry, not the rate-card rows, so a model picked there is still subject to the gate above.\n\n### Effort add-on\n\nAn app can ask for more effort on its fresh generations: more tries, more evaluation rounds, and a higher quality bar for the generator's own check. The add-on is charged on top of the fresh-generation rate, at the level the generation applied. An app that sets no level pays no add-on, and neither do generations on your own model key or cache hits.\n\n| Level           | Add-on    | What it gets                                               |\n| --------------- | --------- | ---------------------------------------------------------- |\n| `low` effort    | no add-on | Up to 5 tries and 1 evaluation round, quality bar 65/100   |\n| `medium` effort | no add-on | Up to 8 tries and 2 evaluation rounds, quality bar 70/100  |\n| `high` effort   | +2¢       | Up to 8 tries and 3 evaluation rounds, quality bar 75/100  |\n| `xhigh` effort  | +9¢       | Up to 10 tries and 3 evaluation rounds, quality bar 80/100 |\n| `ultra` effort  | +25¢      | Up to 12 tries and 4 evaluation rounds, quality bar 85/100 |\n\n## Welcome credit\n\nNew accounts receive a one-time welcome credit on first sign-in while it is available; it appears as a `Free credit` row in the transaction log, the amount shows on `/credits` (and on [ggui.ai/pricing](https://ggui.ai/pricing)), and it does not expire. It is the free entry: renders draw on it at the same rates as on purchased credit, and when it is spent a top-up continues from the same balance.\n\nThe welcome credit is granted to a limited number of new accounts. Once that number is reached, a new account gets no credit, and the console says so instead of showing a $0 balance: \"Welcome credit isn't available right now. Top up to start rendering.\" A top-up works the same way from there.\n\n## Running out\n\nBefore any render runs — pool-funded or on your own provider key, since a BYOK render still charges the platform lane — GGUI checks the **app owner's** balance: the owner's wallet, whichever key made the call. Above zero proceeds; at or below zero is refused with a `402` (`insufficient_credit`) and a fix hint that points at `/credits`, rather than a silent failure.\n\nThe threshold is deliberately \"above zero\", not \"enough to cover this render\". A balance of a few cents still admits the next render, which can drive the balance negative and records the true debt; the next check sees a non-positive balance and refuses. You never get a free loop, and you never get a render cancelled halfway for being a cent short.\n\n## The transaction log\n\nEvery movement is one row: what it was, when, the signed amount, and the balance afterwards. Four kinds appear:\n\n| Kind          | What it is                                                     |\n| ------------- | -------------------------------------------------------------- |\n| `Free credit` | The welcome credit on first sign-in                            |\n| `Render`      | The charge for one render, tagged with the session it paid for |\n| `Top-up`      | Credit purchased through Stripe                                |\n| `Refund`      | Credit returned to the wallet                                  |\n\n## Topping up\n\nClick **Top up** on `/credits`, pick one of the preset amounts (\\$10, \\$50, \\$100) or type a custom one, and check out through Stripe.\n\nCredit lands when Stripe confirms the payment, which is usually a few seconds behind the redirect back to the console. The page keeps refreshing the balance for about half a minute after you return, and offers a manual refresh if the confirmation takes longer. A cancelled checkout charges nothing.\n\n## Coupons\n\nA coupon code (`cpn_…`) is redeemed from the same page. If you belong to any orgs, the redeem form lets you choose where the credit lands — your personal wallet, or any org wallet you are a member of. See [Orgs and teams](/hosted/orgs/) for how shared wallets work.\n\n## Bring your own key (BYOK)\n\nStore a provider API key and GGUI will call that provider on your account rather than out of its own pool. Four providers are supported, each with the console dialog's get-a-key path:\n\n- **Anthropic** — Claude models. `console.anthropic.com` → Settings → API Keys.\n- **OpenAI** — GPT models. `platform.openai.com` → API Keys.\n- **Google** — Gemini models. `aistudio.google.com` → Get API key.\n- **OpenRouter** — multi-provider routing. `openrouter.ai` → Settings → Keys.\n\n### How keys are stored\n\nSubmit the plaintext once. It is validated against the provider, encrypted with KMS under an encryption context bound to your account and the provider, and persisted as ciphertext — it is decrypted in memory only, on the request that needs it. Afterwards the console shows the provider, your label, the last four characters, and when the key was last used. The plaintext is never readable again, by you or by GGUI's own console.\n\nRemove a key at any time from the same card.\n\n### Account-wide keys and per-app overrides\n\n`/keys/providers` holds your account-wide keys. An individual app can override them on its own **Keys** tab, and the app-level key wins for renders bound to that app — a published app can run on its own provider quota instead of drawing on yours.\n\nThe two scopes are encrypted under different contexts, so an app's stored key cannot be decrypted through the account-scoped path, or the reverse.\n\n### What BYOK changes about billing\n\nWhen a render actually runs on your provider key, your provider account pays for the model call and GGUI does not charge for the generation — charging you again would be double billing. A BYOK render carries only GGUI's platform lane rather than the cold-generation or cache-hit rate (rates on [ggui.ai/pricing](https://ggui.ai/pricing)) — and that lane is charged to the same prepaid balance, so the pre-render balance check applies to BYOK renders too: an owner at zero is refused `insufficient_credit` even on their own key. Bringing a key changes who pays for the model call, not whether the wallet is read.\n\n:::caution[A stored key alone does not redirect billing]\nWhether a given app's generation routes to your key is an app-level setting (`generation.keySource`), and an app that has not opted into running on its own key stays pool-funded even with a provider key on file. Flip it in the console — the **Generation billing** card on the app's **Keys** or **Settings** tab — or write it through the app-config API. If you expect BYOK to be paying and the transaction log still shows `Render` charges, this is why.\n:::\n\nResolution is per provider, so mixing is fine: for an app running on its own key, a render routed to a provider you have a key for uses that key, and anything else falls back to the credit pool."},{"path":"/hosted/connector-keys.md","title":"Connector keys","description":"What a ggui_user_* connector key is, the three ways to mint one, how per-app scoping and revocation work, and why it is not the same thing as a marketplace publisher key.","body":"---\ntitle: Connector keys\ndescription: What a ggui_user_* connector key is, the three ways to mint one, how per-app scoping and revocation work, and why it is not the same thing as a marketplace publisher key.\n---\n\nA **connector key** is the bearer token that authenticates something — your agent, or an MCP host acting on your behalf — to hosted GGUI at `mcp.ggui.ai`. Keys are prefixed `ggui_user_` and travel as an ordinary HTTP header:\n\n```\nAuthorization: Bearer ggui_user_…\n```\n\nThat is the whole authentication story for the data plane. No client SDK, no session cookie, no signing step.\n\n## Minting a key\n\nThree paths mint the same kind of row, and all three land in the same list in [the GGUI console](/hosted/console/).\n\n### From the console\n\nOpen **Connector keys** (`/keys/connector`), optionally type a label (`Claude Desktop — laptop`), and click **Generate connector key**. The secret reveals exactly once — copy it before you dismiss the dialog. There is no recovery: lose it and you mint a new one.\n\n### From an MCP host, through OAuth\n\nWhen Claude Desktop or another MCP-Apps-aware client connects to `mcp.ggui.ai`, the [OAuth ceremony](/clients/claude-desktop/) sends your browser to the console's consent screen. **Approve** mints a fresh key and hands it back to the client — you never see or paste it. The default label is built from the requesting client and the date:\n\n```\nMCP — <short client id> — <date>\n```\n\nWhen the host asked for a specific app (an RFC 8707 `resource` indicator naming one of your apps), the app's display name is folded into the same template:\n\n```\nMCP — <appName> — <short client id> — <date>\n```\n\nThe consent screen carries an optional **Key name** field that overrides the default before you approve. **Cancel** mints nothing — the client is redirected back with `error=access_denied` and the MCP server is never contacted.\n\n### From the CLI\n\nSign the CLI in once with the device flow, then mint from your terminal:\n\n```bash\nggui login\nggui keys create --name \"my agent runtime\"\n```\n\nThe secret prints exactly once, GitHub-PAT style. Pass `--expires-at <iso8601>` to give the key a hard expiry. See [`ggui login`](/cli/login/) for the full device-flow walkthrough and the `--keys-file` variant that needs no account at all.\n\n## Account-wide and per-app keys\n\n`/keys/connector` mints **account-wide** keys — they resolve to your default app when a session binds.\n\nAn app's own **Keys** tab (`/apps/[appId]/keys`) mints keys bound to that `appId`, so a session opened with one is locked to that app. The programmatic endpoint for a per-app key carries the app in the path:\n\n```\nhttps://mcp.ggui.ai/apps/<appId>\n```\n\nOAuth flows that carried a per-app `resource` indicator bind the `appId` onto the minted row the same way.\n\n## Listing and revoking\n\nThe console's key list shows one row per key: the `ggui_user_` prefix, the label, created and last-used dates, and status (`active` or `revoked`). It is read-only today — revocation is a CLI operation:\n\n```bash\nggui keys list\nggui keys revoke <id>\n```\n\nRevoke is a soft delete. The row stays for audit, and every subsequent request presenting that key returns `401 Unauthorized`. There is no undo; generate a replacement and reconnect whatever you still want connected.\n\nRevocation is per key, not per host. Every host holding that key loses access at the same moment — which is the argument for issuing one key per host, so you can cut off one without disturbing the rest.\n\nDeleting an app revokes that app's per-app keys as part of the cascade (the rows are kept for audit).\n\n:::note[The key list is the connection list]\nEvery path — console, OAuth, CLI — produces a row in the same table, so that table is the authoritative list of everything that can talk to GGUI as you. The console's **Connected apps** screen is a placeholder; there is no second registry to reconcile against.\n:::\n\n## Connector keys are not publisher keys\n\nTwo different key systems live under the `ggui keys` command. They share a noun and nothing else.\n\n|               | Connector key                              | Publisher key                                                  |\n| ------------- | ------------------------------------------ | -------------------------------------------------------------- |\n| Shape         | `ggui_user_…` bearer secret                | Ed25519 keypair on disk                                        |\n| Purpose       | Authenticate to `mcp.ggui.ai`              | Sign marketplace artifacts                                     |\n| Created by    | Console, OAuth consent, `ggui keys create` | `ggui gadget publish` / `ggui blueprint publish`, on first run |\n| Registered by | Nothing — it works immediately             | [`ggui keys register`](/cli/keys-register/)                    |\n\nIf you are wiring an agent or an MCP host, you want a connector key. Publisher keys only matter when you [publish to the marketplace](/hosted/marketplace/)."},{"path":"/hosted/console.md","title":"console.ggui.ai","description":"User dashboard for the hosted mcp.ggui.ai server — apps, orgs, connector keys, blueprints, credits, BYOK provider keys, and OAuth/CLI consent.","body":"---\ntitle: console.ggui.ai\ndescription: User dashboard for the hosted mcp.ggui.ai server — apps, orgs, connector keys, blueprints, credits, BYOK provider keys, and OAuth/CLI consent.\n---\n\n[`console.ggui.ai`](https://console.ggui.ai) is the user-facing dashboard for the [`mcp.ggui.ai`](/clients/claude-desktop/) hosted MCP server. Sign in with Google, GitHub, or email (Cognito). The left-sidebar nav surfaces ten screens in three task-verb groups — Build, Configure, Account. Three more screens are deep-linked into by the OAuth ceremony, the CLI device flow, and an org-invite email — the last is separate from the **Invites** screen already in the nav, which lists your own pending invites rather than accepting one.\n\n| Group     | Screen         | Path                  | What lives here                                                             |\n| --------- | -------------- | --------------------- | --------------------------------------------------------------------------- |\n| Build     | Apps           | `/apps`               | Per-app surfaces — keys, blueprints, gadgets, marketplace, theme, settings. |\n| Configure | Connector keys | `/keys/connector`     | Mint and list `ggui_user_*` API keys (revoke via the CLI).                  |\n| Configure | Provider keys  | `/keys/providers`     | BYOK — paste your Anthropic / OpenAI / Google / OpenRouter keys.            |\n| Configure | Author keys    | `/keys/authors`       | Publisher Ed25519 signing keys — list and two-step revoke.                  |\n| Configure | Credits        | `/credits`            | Balance, transaction log, coupon redeem, Stripe top-up.                     |\n| Account   | Orgs           | `/orgs`               | Organizations you belong to; org-scoped wallets, invites, members.          |\n| Account   | Invites        | `/invites`            | Your own pending org invites, looked up by email.                           |\n| Account   | Connected apps | `/connections`        | OAuth clients that have access to your account — see below.                 |\n| Account   | Account        | `/account`            | Email, default app, sign out.                                               |\n| Account   | Support        | `/support`            | Contact GGUI — one page, one email, no case system yet.                     |\n| —         | OAuth consent  | `/oauth/consent`      | Approval landing for MCP-Apps-aware clients (Claude Desktop, etc.).         |\n| —         | CLI sign-in    | `/cli-confirm/[code]` | Approval landing for the [`ggui` CLI's](/cli/login/) device flow.           |\n| —         | Org invite     | `/invites/[inviteId]` | Auto-accept landing for an org-invite email link.                           |\n\nThe home path `/` redirects to `/apps` — apps are the primary primitive in the current IA (post-2026-05 apps pivot). You don't visit `/oauth/consent` or `/cli-confirm/*` directly — the MCP host or the CLI navigates you there at the right moment.\n\n:::note\nThe console is the end-user surface for **GGUI** (the protocol, hosted at `mcp.ggui.ai`). **guuey** is the separate agent-hosting platform — a distinct product built on the same protocol underneath.\n:::\n\n:::tip[Also available as an MCP route]\nMost console actions — apps, orgs, connector keys, provider keys, coupons, blueprints, themes — are also exposed as `ops` MCP tools on the control plane at `mcp.ggui.ai/control`. Stripe top-up checkout, author keys, gadget installs, and invite acceptance are console/CLI-only today. See [Control MCP plane](/api/control-mcp/) — useful for operator agents and automation.\n:::\n\n## Apps (`/apps`)\n\nApps are the primary primitive. Each app is a scope that owns its own blueprints, theme, marketplace installs, and per-app API keys. New accounts land here with a single default app already provisioned; create more from this page.\n\nPer-app sub-routes (under `/apps/[appId]/`), in nav order:\n\n- **Overview** (`/apps/[appId]`) — the connect checklist, the MCP connect config snippet, and the app's render activity. This is where the app id lands you.\n- **Blueprints** (`/blueprints`) — the app's blueprint library: view source, rename, edit metadata, delete.\n- **Gadgets** (`/apps/[appId]/gadgets`) — the app's resolved gadget catalog, exactly what agents receive from `ggui_list_gadgets`: the stdlib floor, web-installed rows, and the `ggui.json`-declared overlay (floor < installed < declared). Installed rows get a **Remove** action and a version-pin menu.\n- **Marketplace** (`/marketplace`) — browse and install published blueprints and gadgets; a kind toggle switches the search between the two.\n- **Keys** (`/keys`) — per-app connector keys (`ggui_user_*` rows bound to this `appId`, so the pod locks the session to this app) **and** per-app BYOK provider-key overrides (precedence over the user-pool BYOK keys for renders bound to this app).\n- **Theme** (`/theme`) — per-app theme overrides.\n- **Settings** (`/settings`) — display name, system prompt, per-key rate limit, delete app.\n\nBlueprints are matched during `ggui_handshake`, before any generation. Curated app blueprints match deterministically when the agent's declared tools cover the blueprint's `dataTools` (deterministic, zero-LLM); other cached blueprints are reused by contract similarity. Either way, a hit renders with zero generation cost. See the [generation pipeline](/architecture/overview/#generation-pipeline) for the matching flow and [Marketplace](/hosted/marketplace/) for authoring + distribution.\n\n## Orgs (`/orgs`)\n\nOrganizations you belong to, each with its own credit wallet, member list, and invite flow. Per-org detail lives under `/orgs/[orgId]`, and invite-email links land at `/invites/[inviteId]`. See [Orgs and teams](/hosted/orgs/).\n\n## Connector keys (`/keys/connector`)\n\nEvery `ggui_user_*` key on your account, whichever path minted it — the console, an OAuth ceremony, or the CLI. See [Connector keys](/hosted/connector-keys/).\n\n## Connected apps (`/connections`)\n\nOAuth clients (Claude Desktop, claude.ai, Goose, …) that have registered against your account via Dynamic Client Registration. The per-client list and per-row revoke aren't available yet — OAuth-grant storage is pod-local and not scoped by user, so there is no per-user list to safely show today.\n\nRevocation works today at the **key** level instead: every OAuth approval, console mint, and CLI mint lands in the same table at [Connector keys](/hosted/connector-keys/) (`/keys/connector`). Revoke the `ggui_user_*` row there (via the CLI: `ggui keys revoke <id>`) and every client holding that key loses access immediately — which is also the argument for minting one key per client, so you can cut off one without disturbing the rest.\n\n## Credits (`/credits`)\n\nBalance, transaction log, Stripe top-up, and coupon redemption. See [Credits, billing & BYOK](/hosted/billing/).\n\n## Provider keys / BYOK (`/keys/providers`)\n\nYour account-wide Anthropic / OpenAI / Google / OpenRouter keys, so model calls run on your provider account instead of GGUI's pool. See [Credits, billing & BYOK](/hosted/billing/#bring-your-own-key-byok).\n\n## Account (`/account`)\n\nEmail, default app selection, sign out, and account deletion.\n\n## Ask the console\n\nA persistent agent helper is mounted in the sidebar (cmd-K to focus it; a floating **Ask** launcher opens the same input in a bottom sheet on mobile). Type a natural-language request and it renders its answer as a generative-UI surface inside the console itself — the console dogfooding the same generation it hosts. Saved results group under helper history in the app's Blueprints tab (see [Apps](#apps-apps) above).\n\n## OAuth consent (`/oauth/consent`)\n\nThe browser landing for MCP-Apps-aware clients running through the [OAuth ceremony](/self-hosted/oauth/). When Claude Desktop (or any compatible host) asks `mcp.ggui.ai` to authorize, the server 302s the browser here with the OAuth params.\n\nYou see:\n\n- The requesting client's name (from DCR `client_name`).\n- The scope being requested (`mcp`).\n- Two buttons: **Approve** / **Cancel**.\n\n**Approve** mints a fresh `ggui_user_*` key (default label `MCP — <shortClientId> — <date>`, editable via the Key-name field; per-app flows that carried an RFC 8707 `resource` get the `appId` bound on the row so the session locks to that app) and posts it back to the MCP server through a cross-origin form POST. The server completes the OAuth flow and the client's next `/mcp` request authenticates with the new key.\n\n**Cancel** redirects back to the client with `error=access_denied` per [RFC 6749 §4.1.2.1](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1). The MCP server is never contacted; nothing is minted.\n\nYou don't visit this URL directly — the MCP host sends you here at OAuth time.\n\n## CLI sign-in (`/cli-confirm/[user_code]`)\n\nThe browser landing for the [`ggui login`](/cli/login/) device flow. The CLI prints a URL like:\n\n```\nhttps://console.ggui.ai/cli-confirm/AB12-CD34\n```\n\nOpen it, sign in if you aren't already, confirm the codes match, click **Approve**. The CLI's polling on `/v1/auth/poll` flips to `approved` and tokens land at `~/.ggui/auth.json`.\n\nSame trust model as the OAuth consent screen — confirm the code matches what the CLI printed before approving.\n\n## Account model\n\nOne Cognito user owns one GGUI account. User-scoped data on the dashboard (your keys, your blueprints, your personal credit balance, your provider keys) is visible only to you via owner-auth on every model. Org-scoped data (org wallet, org members, org-scoped blueprints) is gated by org membership."},{"path":"/hosted/marketplace.md","title":"Marketplace Registry","description":"Publish and install gadgets + blueprints from the GGUI marketplace. Versioned, Ed25519-signed, content-addressable distribution for GGUI artifacts.","body":"---\ntitle: Marketplace Registry\ndescription: Publish and install gadgets + blueprints from the GGUI marketplace. Versioned, Ed25519-signed, content-addressable distribution for GGUI artifacts.\n---\n\nThe marketplace registry is where authors publish GGUI artifacts and consumers install them. It's HTTP-only — distinct from the WebSocket protocol — and hosts immutable, signed, content-addressable bundles + manifests. The registry is for published, signed artifacts; to move your own deployment's cached blueprints between servers, use `ggui export-pool` and load the pool on the other server with `ggui serve --seed-pool <dir>`.\n\n## Three nouns, three roles\n\nMarketplace docs and registry wire shapes use exactly three terms. Do not say \"plugin\" / \"library\" / \"package\" — see the [glossary](/glossary/) for the canonical vocabulary.\n\n| Noun          | Role                                      | Where it lives                                  |\n| ------------- | ----------------------------------------- | ----------------------------------------------- |\n| **gadget**    | ingredient — composed into a UI           | `clientCapabilities.gadgets` (client-side hook) |\n| **tool**      | action — invoked by the agent             | `agentCapabilities.tools` (agent-side function) |\n| **blueprint** | recipe — returned directly as a cached UI | TSX source + optional contract + variance hints |\n\nA gadget is a `GadgetDescriptor` (see [Gadgets SDK](/sdk/gadgets/)) packaged for distribution. A blueprint is a `(source, contract?, fixtureProps?, variance?)` quad. Both ride `ArtifactManifest` — a discriminated union on `kind: \"gadget\" | \"blueprint\"`.\n\n## Browse the registry\n\n[hub.ggui.ai](https://hub.ggui.ai) is the public, unauthenticated browser over the same registry — no account, no CLI. It lists recently published artifacts, has a search page (`/search`) with filters for kind (blueprint / gadget) and MCP binding (`tool=`, `server=` — find every artifact that works with a given tool or server), and a per-version page for each artifact showing the manifest, signing metadata (algorithm, verified-publisher badge where applicable), and a ready-to-copy install command. It reads only the registry's public CORS-open endpoints, so it shows exactly what `ggui gadget search` / `ggui blueprint search` show — same catalog, browsable form.\n\n## Registry resolution\n\nThe hosted registry lives at `registry.ggui.ai`; self-hosted registries work identically (see [Self-Hosted Registry](/sdk/self-hosted-registry/)).\n\nEvery verb resolves the registry URL through the same chain (highest precedence first): `--registry <url>` flag → `GGUI_REGISTRY` env → `ggui.json#registry` (walking up from CWD). The verbs differ only on a full miss, following the npm model:\n\n- **Read verbs** (`install`, `search`) fall back to the public default `https://registry.ggui.ai` — zero config works out of the box.\n- **Write verbs** (`publish`, `keys register`) error if none is set — a publish target must be opted into explicitly.\n\n## CLI surface — two sibling verbs\n\nTwo parallel namespaces, one per artifact kind. Each verb hard-enforces its kind from the manifest — running a gadget verb in a blueprint repo (or vice versa) emits a friendly redirect and exits non-zero.\n\n| Verb                                | What it does                                                                                                                                                                                  |\n| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `ggui gadget create <id>`           | Scaffold `ggui.gadget.json` + `src/index.ts` hook stub                                                                                                                                        |\n| `ggui gadget publish`               | Bundle (esbuild), sign, upload from CWD                                                                                                                                                       |\n| `ggui gadget search [query]`        | List gadgets (`--kind=gadget` hard-locked)                                                                                                                                                    |\n| `ggui gadget install <id>@<v>`      | Verify + register into `ggui.json#app.gadgets[]`                                                                                                                                              |\n| `ggui gadget uninstall <id>@<v>`    | Remove the installed `(package, version)` entry from `ggui.json#app.gadgets[]` (reverse of install; the emptied array is dropped)                                                             |\n| `ggui blueprint create <id>`        | Scaffold `ggui.blueprint.json` + `src/blueprint.tsx` + optional contract stub                                                                                                                 |\n| `ggui blueprint publish`            | Sign + upload from CWD (no bundling — TSX rides inline on the manifest)                                                                                                                       |\n| `ggui blueprint search [query]`     | List blueprints (`--kind=blueprint` hard-locked)                                                                                                                                              |\n| `ggui blueprint install <id>@<v>`   | Materialize to `.ggui/installed-blueprints/<scope>__<name>__<version>/` for `discoverLocalUis` to pick up                                                                                     |\n| `ggui blueprint uninstall <id>@<v>` | Remove a locally-installed blueprint (deletes the materialized directory; when it was the last installed blueprint, also strips the install-managed glob from `ggui.json#blueprints.include`) |\n\nThe two namespaces are deliberately separate sibling verbs — NOT an umbrella `ggui artifact` that auto-detects kind. Gadgets and blueprints share a registry but mean different things to the install flow, so the kind discriminator is surfaced at the verb level.\n\n## Author workflow\n\n```bash\n# === Gadget ===\n\n# 1. Scaffold\nggui gadget create @my-org/weather-card\ncd weather-card\n\n# 2. Implement the hook + edit ggui.gadget.json (description, usage,\n#    example, gotchas — these are required and shape the LLM's prompt\n#    at code-gen time, so write them carefully).\n\n# 3. Authenticate (the publish flow reuses the session `ggui login`\n#    stores at ~/.ggui/auth.json, refreshing it automatically).\nggui login\n\n# 4. Dry-run validates bundle + conformance preflight without\n#    uploading. Recommended on every change.\nggui gadget publish --dry-run\n\n# 5. Upload. First publish auto-generates an Ed25519 keypair and\n#    registers your public key with the registry.\nggui gadget publish\n\n# === Blueprint ===\n\n# 1. Scaffold (different template — TSX source + contract stub)\nggui blueprint create @my-org/login-form\ncd login-form\n\n# 2. Edit src/blueprint.tsx + the contract; manifest carries `variance`\n#    hints that prime the matcher.\n\n# 3. Same auth setup as gadgets.\n\n# 4. Publish — no bundling step; TSX rides inline on the manifest.\nggui blueprint publish\n```\n\nThe publish CLIs run a shared internal pipeline that asserts `manifest.kind === verb-kind`, runs the registry's conformance gate, signs (gadget: `sha384(bundleBytes)` of the compiled bundle; blueprint: canonical JSON of the manifest), and POSTs `{manifest, bundle?, signature}` to `/publish`.\n\n## Consumer workflow\n\n```bash\n# Search the registry — each namespace hard-locks its kind filter.\nggui gadget search \"weather\"\nggui gadget search --hook=useWeatherCard\nggui blueprint search \"login\"\n\n# Install. Verifies signature + SRI before writing anything.\nggui gadget install @my-org/weather-card@0.1.0      # → ggui.json#app.gadgets\nggui blueprint install @my-org/login-form@0.1.0     # → .ggui/installed-blueprints/\n```\n\nInstall always runs both verification legs (bundleSha384 recompute + Ed25519). If the registry didn't pin a public key on the version (legacy / pre-key-pinning artifacts), the default behavior is **warn-and-continue**; pass `--strict` to turn that into a hard exit 1 instead.\n\n## Installing gadgets from the GGUI console\n\nThe CLI is the config-managed path (`ggui.json#app.gadgets[]`, versioned alongside your deployment). On hosted GGUI there's also a per-app web path: an app's **Marketplace** tab in the GGUI console has a Blueprints / Gadgets toggle over the same registry search — pick Gadgets, find a package, and Install. It writes the latest version onto that app's row in a separate provenance-split table, distinct from the CLI-managed `ggui.json` column. The app's **Gadgets** tab shows the resolved catalog an agent actually gets from `ggui_list_gadgets`, and lets you remove a web-installed gadget or pin it to a different version (re-installing at a picked version is the pin operation).\n\nResolution when both paths touch the same package follows a fixed precedence, stdlib floor < console-installed < ggui.json-declared:\n\n- A package declared in `ggui.json` always wins, and the web install path never writes that column — attempting to install a package that's already declared there is rejected with `package_declared`, pointing you back to the config-managed path instead.\n- A console-installed package overrides the stdlib floor but is itself overridden the moment the same package is declared in `ggui.json`.\n- Nothing installed anywhere falls through to the stdlib floor.\n\nUse the console path for exploration and per-app changes without a deploy; use `ggui gadget install` when the gadget set needs to be versioned with the rest of your app's config.\n\n## Auth\n\n`publish` (always) and private-row reads accept `--auth=bearer --token=<value>` (or `GGUI_REGISTRY_TOKEN`) for self-hosted / local-development flows. Without the flag, the CLI sends the access token from your stored [`ggui login`](/cli/login/) session (`~/.ggui/auth.json`), refreshing it automatically when expired. The hosted registry's authenticated endpoints (`/publish`, `/conformance/check`, `/author-keys`) accept exactly this credential — run `ggui login` once and the default auth path works end-to-end. Third parties running their own [`@ggui-ai/registry-server`](/sdk/self-hosted-registry/) deployments pass `--auth=bearer`.\n\n## Trust model\n\nThe signing posture is determined by `manifest.visibility`. Both posters share the same `bundleSha384`-recompute fast leg; they differ only on the cryptographic verify leg.\n\n| `visibility` | Algorithm       | Trust root                                  | Key/identity binding                                                   |\n| ------------ | --------------- | ------------------------------------------- | ---------------------------------------------------------------------- |\n| `public`     | sigstore-cosign | Fulcio cert + Rekor inclusion proof         | OIDC subject embedded in the Fulcio leaf cert                          |\n| `private`    | Ed25519         | AuthorKeys table (per-publisher pinned key) | `publicKeyId` = `base64(sha256(publicKey))[:16]` (16-char fingerprint) |\n\nThe pairing is enforced **server-side at publish**, not just by the CLI: `POST /publish` rejects a mismatched pair with `400 visibility_algorithm_mismatch`. `public` requires `sigstore-cosign` so every publicly listable artifact has a transparency-log entry; `private` requires an Ed25519 author key. A hand-rolled request can't opt a public artifact out of the transparency log by signing it with a private key.\n\n**Per-author Ed25519 keys (private gadgets).** On first publish, the CLI generates a 32-byte private key at `~/.ggui/keys/<scope>/private.key`. The public key (`base64(pk)`) is registered with the registry under the publisher's server-side account subject — see [`ggui keys register`](/cli/keys-register/) for the explicit verb and the registration error matrix. The registry copies the publishing key onto every `ArtifactVersionRow` it writes (field: `authorPublicKey`, base64-encoded raw Ed25519 public key bytes). A later key rotation does NOT invalidate historical versions — each version verifies against the exact key that signed it.\n\n**Sigstore (public gadgets).** Public artifacts sign via the keyless sigstore flow — the CLI obtains a short-lived Fulcio certificate bound to your OIDC identity and submits the signature to Rekor for a public transparency-log entry. There is no long-lived author key for the public path; trust roots in Fulcio + Rekor.\n\n**Two-leg verification on install.** Both must pass before any disk write:\n\n1. **bundleSha384 recompute.** Compute `sha384(payload)`, compare against `signature.bundleSha384`. Fast tamper detection. Runs for both algorithms.\n2. **Cryptographic verify.** Dispatches on `signature.algorithm`:\n   - `ed25519` → `derivePublicKeyId(returnedPubkey) === signature.publicKeyId` (rejects key-swap attacks), then `verifyBundleEd25519`. If the registry didn't pin a public key for the version, the CLI warns and continues by default; `--strict` turns the warn into a hard fail.\n   - `sigstore-cosign` → `verifyBundleSigstore` validates the Fulcio cert chain + Rekor inclusion. By default any valid OIDC identity is accepted; pass `--verify-identity <subject-or-/regex/>` to additionally pin the Fulcio leaf's subject claim.\n\n## Subresource integrity (`bundleSri`)\n\nPublish computes `sha384-<base64(sha384(bundle))>` server-side. The read endpoint returns it; install writes it onto `GadgetDescriptor.bundleSri`; the iframe runtime emits a `<link rel=\"modulepreload\" integrity=\"...\">` tag before dynamic-importing the bundle. A CDN compromise can't silently swap the bundle without breaking SRI.\n\nCSP `script-src` auto-derives from each entry's `bundleUrl` origin — no per-registry allowlist needed.\n\n## Versioning\n\n- **Semver.** `1.2.3-alpha.1+build.42` form. `parseArtifactManifest` rejects ranges, `latest`, leading zeros (applies to gadgets and blueprints alike).\n- **Immutable.** `POST /publish` of an existing `(scope, name, version)` triple returns `409 version_exists`.\n- **Yank, not delete.** Yanked versions return `410 Gone` with the manifest still in the body for audit. Hard delete is intentionally NOT supported.\n\n## HTTP API surface (core routes)\n\nThe core routes (version-listing `GET /pkg/{scope}/{name}` and the `/bundles/*` fetch routes ride alongside):\n\n| Method | Path                                                  | Auth        | Purpose                                                                                                                                                             |\n| ------ | ----------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `GET`  | `/search?q=&kind=&hook=&tag=&author=&tool=&server=&…` | public      | List artifacts. AND-semantics across filters. `tool` / `server` filter on an artifact's MCP tool bindings (find everything that works with a given tool or server). |\n| `GET`  | `/pkg/{scope}/{name}/{version}`                       | mixed[^1]   | Read a single version's manifest + fetchable URLs + signing metadata.                                                                                               |\n| `POST` | `/publish`                                            | CLI session | Upload `{manifest, bundle?, bundleSha384?, signature}`.                                                                                                             |\n| `POST` | `/conformance/check`                                  | CLI session | Dry-run the conformance gate without publishing.                                                                                                                    |\n\n[^1]: Public for `visibility: \"public\"` artifacts. A `visibility: \"private\"` version is served only to the verified account that published it or that owns the artifact's scope; every other request — anonymous or authenticated — receives the same `not_found` response as a genuinely missing artifact (the version list likewise filters versions you cannot read), so the API never confirms a private artifact exists. \"CLI session\" = the `ggui login` session token, verified at the hosted deployment's gateway (see [Auth](#auth)).\n\nWire shapes are defined in `@ggui-ai/registry-core` (shared by the hosted cloud and the self-hosted server).\n\n## What's in `ggui.json` after install\n\n**Gadgets** land on `app.gadgets[]`:\n\n```json\n{\n  \"app\": {\n    \"gadgets\": [\n      {\n        \"package\": \"@my-org/weather-card\",\n        \"version\": \"0.1.0\",\n        \"bundleUrl\": \"https://registry.ggui.ai/bundles/...\",\n        \"bundleSri\": \"sha384-Wj1...\",\n        \"exports\": [\n          {\n            \"hook\": \"useWeatherCard\",\n            \"description\": \"Renders a weather card.\",\n            \"usage\": \"Use to surface current weather.\",\n            \"example\": { \"city\": \"Berlin\" }\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\n**Blueprints** materialize to disk under `.ggui/installed-blueprints/<scope>__<name>__<version>/`:\n\n```\n.ggui/installed-blueprints/\n└── my-org__weather-card__0.1.0/\n    ├── index.tsx         # the published TSX source\n    └── ggui.ui.json      # generated UI manifest (id, contract, entryPoint)\n```\n\nThe install adds `.ggui/installed-blueprints/**/ggui.ui.json` to `ggui.json#blueprints.include` automatically. The existing `discoverLocalUis` glob loader picks them up at boot — no special-case install path on the server side.\n\n## Agent-facing MCP tools\n\nThe marketplace surface for the _agent_ (not the publisher CLI) lives on the same MCP endpoint as everything else. Three tools enumerate and materialize blueprints from the registry-backed catalog:\n\n| Tool                            | Purpose                                                                                                                                            |\n| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `ggui_list_featured_blueprints` | Enumerate builder-curated featured blueprints (no filters; takes empty input).                                                                     |\n| `ggui_search_blueprints`        | Semantic search across the app's blueprints (manifest + cached generations).                                                                       |\n| `ggui_render_blueprint`         | Resolve a registered blueprint by id to its compiled JS bundle — returns `{blueprintId, blueprintName, code, contentType}`; no session is created. |\n\nFull wire shapes + agent-side usage: [MCP Protocol Reference](/api/mcp-protocol/#ggui_list_featured_blueprints). Gadgets are advertised via [`ggui_list_gadgets`](/api/mcp-protocol/#ggui_list_gadgets) instead — the catalog rides on the contract, not as a separate \"render\" call.\n\n## Roadmap\n\nTracked-but-not-yet-shipped surfaces, in rough priority order:\n\n- **Per-org private artifacts.** `visibility: \"private\"` reads are enforced per-subject today — the publisher or the scope owner reads a private version; everyone else gets an opaque `not_found`. Org-membership admission (a teammate reading an org-mate's private versions) is the next step.\n- **Yank UX.** No `ggui gadget yank` subcommand yet — yanking goes through registry admin tooling. The read + install sides already handle `410 Gone` correctly (install exits 1 with a \"yanked\" message).\n- **Cross-registry federation / mirror.** Single-registry installs only; federated lookup is a separate slice.\n- **Per-user authentication.** Author identity is the hosted account's subject today; team workflows share one account. Per-user identity within a team is future work.\n\n## Reference\n\n- **Browse.** [hub.ggui.ai](https://hub.ggui.ai) — the public registry browser (search, per-version manifests, install commands).\n- **Protocol spec.** [Protocol overview](/protocol/overview/)\n- **Self-hosting.** [Self-hosted registry](/sdk/self-hosted-registry/) — byte-compatible `@ggui-ai/registry-server` for air-gapped or local-dev use.\n- **Glossary.** [gadget / tool / blueprint](/glossary/) — the three-noun vocabulary used throughout.\n- **Author manifest.** [`@ggui-ai/artifact-manifest`](/sdk/gadgets/) — `gadgetManifestSchema` / `blueprintManifestSchema`.\n- **Signing.** [`@ggui-ai/gadget-signing`](/sdk/gadgets/) — universal Ed25519 (browser + Node).\n- **Publisher key registration.** [`ggui keys register`](/cli/keys-register/)."},{"path":"/hosted/orgs.md","title":"Orgs & teams","description":"Shared workspaces on hosted GGUI — the pooled org wallet, the owner/admin/member role model, the email invite flow, and how an org is dissolved.","body":"---\ntitle: Orgs & teams\ndescription: Shared workspaces on hosted GGUI — the pooled org wallet, the owner/admin/member role model, the email invite flow, and how an org is dissolved.\n---\n\nAn org is a shared workspace with a pooled credit wallet. Members you invite render against the org's wallet, so nobody needs their own balance topped up individually, and a single coupon can credit the whole team.\n\nOrgs live at `/orgs` in [the GGUI console](/hosted/console/). Every org you belong to is listed there — the ones you created and the ones you were invited into — badged with your role.\n\n:::note[Orgs are not hosting]\nAn org shares a wallet, a member list, and org-scoped blueprints. Production agent hosting and shared operational dashboards belong to **guuey**, the separate agent-hosting platform built on the same protocol.\n:::\n\n## Creating an org\n\nGive it a name and confirm. Left blank, it is created as \"My GGUI org\". You are its first owner.\n\n## Roles\n\nEvery membership carries one of three roles.\n\n|                                 | member | admin | owner |\n| ------------------------------- | ------ | ----- | ----- |\n| Render against the org wallet   | yes    | yes   | yes   |\n| See the wallet balance          | yes    | yes   | yes   |\n| Redeem a coupon into the wallet | yes    | yes   | yes   |\n| Invite members                  | no     | yes   | yes   |\n| Rename the org                  | no     | yes   | yes   |\n| Remove a member                 | no     | yes   | yes   |\n| Remove an admin                 | no     | no    | yes   |\n| Change someone's role           | no     | no    | yes   |\n| Dissolve the org                | no     | no    | yes   |\n\nAnyone can leave an org on their own — an owner only once a second owner exists, so an org is never left ownerless. For the same reason the last remaining owner cannot be demoted; promote someone else first.\n\nOwners cannot remove other owners directly. Demote them to admin or member, then remove them.\n\n## Inviting people\n\nOwners and admins invite by email from the org's detail page, choosing the new member's role (member or admin). The recipient gets an email with a one-time accept link.\n\nThat link is the credential. The invite id in the URL is what authorizes the claim — anyone signed in who holds the link can accept it, and it is not matched against the recipient's email address. Send it to the person, not to a channel.\n\nInvites expire, and pending ones are listed on the org page with their target email, role, and expiry, each with a **Revoke** action. Inviting the same address twice reuses the existing pending invite rather than issuing a second one.\n\nFollowing the link lands the recipient on `/invites/[inviteId]`. If they are not signed in they see the sign-in form first; once authenticated the invite accepts itself and drops them straight into the org.\n\n## The org wallet\n\nEach org carries its own wallet, shown on the org page with the current balance alongside lifetime granted and lifetime spent. Any member can see it.\n\nCredit reaches an org wallet through coupon redemption: on `/credits`, the redeem form offers your personal wallet or any org you belong to as the destination. Personal balances and org balances are separate ledgers — see [Credits, billing & BYOK](/hosted/billing/) for how charges are priced.\n\n## Members\n\nThe member list shows each person's display name or email, their role, and when they joined. Owners change roles inline from that list. Removing someone takes effect immediately; your own row offers **Leave** instead.\n\n## Dissolving an org\n\nAn owner can schedule dissolution from the org's danger zone. The org is deleted after a seven-day grace period, and any owner can cancel during that window. While it is scheduled, the org shows a `dissolving` badge in the list and a banner on its detail page."},{"path":"/hosted/playgrounds/mdh.md","title":"Million-Dollar Homepage playground","description":"Hosted MCP service at mcp.ggui.ai/playground/the-million-dollar-homepage — shared 1000×1000 pixel grid. Demo of generative UI rendering globally shared mutable state.","body":"---\ntitle: Million-Dollar Homepage playground\ndescription: Hosted MCP service at mcp.ggui.ai/playground/the-million-dollar-homepage — shared 1000×1000 pixel grid. Demo of generative UI rendering globally shared mutable state.\n---\n\n`mcp.ggui.ai/playground/the-million-dollar-homepage` is a first-party hosted MCP service that exposes a globally shared 1000×1000 pixel grid — a tribute to [Alex Tew's 2005 Million-Dollar Homepage](https://en.wikipedia.org/wiki/The_Million_Dollar_Homepage). Every signed-in user reads and writes the same canvas. Where the [Todos playground](/hosted/playgrounds/todos/) demonstrates per-user persistent state, MDH demonstrates **globally shared mutable state** rendered as generative UI.\n\nThree tools, one canvas, last-write-wins semantics. The agent claims a pixel, the host renders the new region inline, the next user can claim right over the top.\n\n## What it demonstrates\n\n- **Shared mutable state across users**: every claim is visible to every other signed-in caller immediately.\n- **Generative UI over a sparse data shape**: `mdh_read_region` returns only claimed cells, so the host renders a sparse grid — pure data, no markup decisions baked into the tool.\n- **Attribution without ownership**: each claimed cell records the writer's `userId`, but anyone can overwrite. Identity is recorded; tenure is not.\n\nIf you want the deep \"why\" of the service model behind this, see [MCP services](/architecture/mcp-services/) — `playground-mdh` is one of three reference services in `cloud/mcp-services/`.\n\n## The three tools\n\nAll three are Cognito-gated. Anonymous calls (no bearer token) return an explicit `authentication required` error.\n\n| Tool              | Input                     | Output                                                 | Notes                                                                             |\n| ----------------- | ------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------- |\n| `mdh_read_region` | `{ x, y, width, height }` | `{ pixels: Pixel[] }`                                  | Sparse — unclaimed cells omitted. Region capped at 100×100 cells per call.        |\n| `mdh_claim_pixel` | `{ x, y, color }`         | `{ pixel }`                                            | Single-cell write. `color` is `#RRGGBB` hex. Last-write-wins (see warning below). |\n| `mdh_get_stats`   | `{}`                      | `{ totalClaimed, uniqueUsers, gridWidth, gridHeight }` | Cheap aggregate read. `gridWidth` and `gridHeight` are always 1000.               |\n\nA `Pixel` row is `{ x, y, color, userId, claimedAt }`. Coordinates are integers, `0 <= x < 1000` and `0 <= y < 1000`. `claimedAt` is ISO-8601.\n\nThe region cap exists to bound payload size: a 100×100 fully-claimed read returns 10,000 entries. Agents wanting a wider view issue multiple reads — the sparse response means the cost scales with claimed density, not region size.\n\n:::caution[Last-write-wins, no permanent ownership]\n`mdh_claim_pixel` REPLACES any existing claim on that cell. The most recent writer's `userId` overwrites the previous one. There is no \"owned forever\", no allow-list, no first-claim precedence. Anyone signed in can paint over anything. The wire description on the tool itself spells this out so agents surface it before the user buys in.\n:::\n\n## Auth model\n\nCognito-gated. Every handler resolves `ctx.userId` from the bearer key minted by [console.ggui.ai](/hosted/console/) and rejects calls without one. On `mdh_claim_pixel`, that same `userId` is stamped on the resulting pixel row — that's the attribution surface visible to subsequent `mdh_read_region` callers.\n\nReads are gated too. The canvas is end-user surface, not a public dataset; anonymous reads are deliberately not exposed at this slice.\n\n## Try it\n\nThe wiring is identical to the [Todos playground](/hosted/playgrounds/todos/) — same auth header, same OAuth ceremony from MCP-Apps-aware hosts, different URL.\n\n**Claude Desktop**: Settings → Connectors → Add custom connector, paste:\n\n```\nhttps://mcp.ggui.ai/playground/the-million-dollar-homepage\n```\n\nApprove the OAuth ceremony at `console.ggui.ai`; a new `ggui_user_*` row appears in [`/keys/connector`](https://console.ggui.ai/keys/connector). Then try prompts like:\n\n> Show me a 50×50 region around the center of the canvas.\n\n> Claim the pixel at (500, 500) in red.\n\n> How many pixels have been claimed total, and by how many users?\n\n**Claude Agent SDK** (or any host that takes a remote MCP URL):\n\n```typescript\nimport { query } from \"@anthropic-ai/claude-agent-sdk\";\n\nconst apiKey = process.env.GGUI_USER_KEY!; // ggui_user_*\n\nconst mcpServers = {\n  mdh: {\n    type: \"http\" as const,\n    url: \"https://mcp.ggui.ai/playground/the-million-dollar-homepage\",\n    headers: { Authorization: `Bearer ${apiKey}` },\n  },\n};\n\nfor await (const msg of query({\n  prompt: \"Claim (500, 500) in #ff0044, then show me a 20×20 region around it.\",\n  options: {\n    model: \"claude-sonnet-5\",\n    mcpServers,\n    allowedTools: [\n      \"mcp__mdh__mdh_read_region\",\n      \"mcp__mdh__mdh_claim_pixel\",\n      \"mcp__mdh__mdh_get_stats\",\n    ],\n  },\n})) {\n  // consume the SDK message stream\n}\n```\n\nThe Claude Agent SDK namespaces every MCP tool as `mcp__<serverName>__<toolName>` — with `serverName: \"mdh\"` here, the tool ids land as `mcp__mdh__mdh_read_region`, `mcp__mdh__mdh_claim_pixel`, and `mcp__mdh__mdh_get_stats`. For the full agent-side wiring (system prompt, error handling, streaming), see the [Claude Agent example](/examples/claude-agent/) and swap the URL + tool whitelist.\n\n## How it's built\n\nThe service is closed-source (`@ggui-private/mcp-playground-mdh` — not published or mirrored), but its shape is simple enough to describe:\n\n- a package entry exporting a `createPlaygroundMdhHandlers({ grid })` convenience factory,\n- one file per tool (read-region / claim-pixel / get-stats),\n- a `PixelGrid` interface with an in-memory implementation. The grid is the single source of truth for its own dimensions and surfaces them on the wire via `mdh_get_stats`.\n\nTo build your own globally shared state surface in this shape, use the OSS **`McpService` mount primitive** in [`@ggui-ai/mcp-server`](/architecture/mcp-services/) — the same seam this service mounts through. The [MCP services architecture](/architecture/mcp-services/) page covers mount points, the `AuthAdapter` contract, and how `ctx.userId` flows from the bearer header to the handler."},{"path":"/hosted/playgrounds/todos.md","title":"Todos playground","description":"Hosted MCP service at mcp.ggui.ai/playground/todos — a per-user persistent todo list demo. Try generative UI rendering shared state.","body":"---\ntitle: Todos playground\ndescription: Hosted MCP service at mcp.ggui.ai/playground/todos — a per-user persistent todo list demo. Try generative UI rendering shared state.\n---\n\n`mcp.ggui.ai/playground/todos` is a first-party hosted MCP service that exposes a per-user persistent todo list. Point an agent at it and watch the agent call a tool while GGUI renders the list inline as generative UI. State survives refresh, re-login, and host swaps — todos are pinned to your Cognito identity, not the session.\n\n## Try it from Claude Desktop\n\nThis is one more connector on top of the [Claude Desktop walkthrough](/clients/claude-desktop/), not a zero-setup surface. If you've already connected `mcp.ggui.ai` there, Claude Desktop will pick up `playground-todos` once you add a second connector. In Settings → Connectors → Add custom connector, paste:\n\n```\nhttps://mcp.ggui.ai/playground/todos\n```\n\nThe OAuth ceremony runs again against `console.ggui.ai`; approve it the same way you did for the main `mcp.ggui.ai` connector. A new `ggui_user_*` row appears in [`/keys/connector`](https://console.ggui.ai/keys/connector) labelled with the connector name.\n\nOnce connected, try prompts like:\n\n> What's on my todo list?\n\n> Add \"ship the launch post\" to my todos, then show me everything pending.\n\n> Mark the first todo as done.\n\nClaude calls `todos_list` / `todos_add` / `todos_toggle`, and the table updates in the chat. Refresh Claude Desktop, ask again — same list comes back.\n\n## Try it from your own agent\n\nThe Todos playground is a vanilla remote MCP server. Anything that speaks `streamable-http` MCP + bearer auth works. For Claude Agent SDK:\n\n```typescript\nimport { query } from \"@anthropic-ai/claude-agent-sdk\";\n\nconst apiKey = process.env.GGUI_USER_KEY!; // ggui_user_*\n\nconst mcpServers = {\n  todos: {\n    type: \"http\" as const,\n    url: \"https://mcp.ggui.ai/playground/todos\",\n    headers: { Authorization: `Bearer ${apiKey}` },\n  },\n};\n\nfor await (const msg of query({\n  prompt: \"Add 'try the playground' to my todos, then list everything.\",\n  options: {\n    model: \"claude-sonnet-5\",\n    mcpServers,\n    allowedTools: [\n      \"mcp__todos__todos_list\",\n      \"mcp__todos__todos_add\",\n      \"mcp__todos__todos_toggle\",\n      \"mcp__todos__todos_delete\",\n    ],\n  },\n})) {\n  // consume the SDK message stream\n}\n```\n\nThe Claude Agent SDK namespaces every MCP tool as `mcp__<serverName>__<toolName>` — with `serverName: \"todos\"` here, the tool ids land as `mcp__todos__todos_list`, `mcp__todos__todos_add`, and so on. The pattern is the same for any other host that takes an MCP-server URL.\n\nFor the full agent-side wiring (system prompt, error handling, streaming), see the [Claude Agent example](/examples/claude-agent/). The same pattern applies — swap `mcp.ggui.ai` for `mcp.ggui.ai/playground/todos`, narrow the tool whitelist to the four todos tools.\n\n## How it works\n\nThis is the smallest \"real\" MCP-driven surface we ship. No code to write, no schema to learn — the four tools are described by the server's `tools/list` response, and the host (Claude Desktop, Goose, your own agent) drives them.\n\n- **Agent → tool → generative UI**: the agent calls a tool, the runtime feeds the result into a blueprint, the host renders the resulting list inline.\n- **Per-user persistence**: log out, come back tomorrow from a different host — same todos.\n- **Cognito identity at the MCP edge**: tool handlers read `ctx.userId` directly. No second auth layer; the `Authorization: Bearer ggui_user_*` header that authenticated the MCP session also scopes the data.\n\nIf you want the deep \"why\" of the service model that makes this work, see [MCP services](/architecture/mcp-services/) — `playground-todos` is one of three reference services in `cloud/mcp-services/`.\n\n### The four tools\n\nAll four are scoped to the caller's `ctx.userId`. Anonymous calls (no bearer token) return an explicit `authentication required` error.\n\n| Tool           | Input      | Output                                 | Notes                                                                              |\n| -------------- | ---------- | -------------------------------------- | ---------------------------------------------------------------------------------- |\n| `todos_list`   | `{}`       | `{ todos: Todo[] }`                    | Oldest-first by `createdAt`. Empty array when the user has none — never `null`.    |\n| `todos_add`    | `{ text }` | `{ todo }`                             | `text` trimmed; empty or 500+ char bodies rejected at the schema boundary.         |\n| `todos_toggle` | `{ id }`   | `{ found: boolean, todo: Todo\\|null }` | Flips `done`. Missing id and cross-user id collapse to one shape (see Auth model). |\n| `todos_delete` | `{ id }`   | `{ ok: boolean }`                      | `ok: true` only when the caller owned a todo with that id.                         |\n\nA `Todo` row is `{ id, text, done, createdAt }`. `createdAt` is an ISO-8601 string; `id` is opaque (treat it as a black-box handle returned by `todos_add` or `todos_list`).\n\n### Auth model\n\nCognito-gated. Every handler resolves `ctx.userId` from the bearer key minted by [console.ggui.ai](/hosted/console/) and rejects calls without one. Those keys come from the console (mint at `/keys/connector` or approve an OAuth ceremony from an MCP-Apps-aware host) or from `ggui keys create` after [`ggui login`](/cli/login/).\n\nCross-user probing is structurally prevented. `todos_toggle` and `todos_delete` collapse two failure modes into one wire shape:\n\n- The id doesn't exist.\n- The id exists but belongs to a different user.\n\nBoth return the same negative response (`found: false` / `ok: false`). A caller can't enumerate other users' id space.\n\n### How it's built\n\nThe service is closed-source (`@ggui-private/mcp-playground-todos` — not published or mirrored), but its shape is simple enough to describe:\n\n- a package entry exporting a `createPlaygroundTodosHandlers({ store })` convenience factory,\n- one file per tool (list / add / toggle / delete), each ~50 lines,\n- a `TodoStore` interface with an in-memory implementation for tests; production swaps in a DynamoDB-backed store against the same interface.\n\nTo build your own service in this shape, use the OSS **`McpService` mount primitive** in [`@ggui-ai/mcp-server`](/architecture/mcp-services/) — the same seam this service mounts through. The [MCP services architecture](/architecture/mcp-services/) page covers mount points, the `AuthAdapter` contract, and how `ctx.userId` is resolved end-to-end."},{"path":"/hosted/portability.md","title":"Portability","description":"Hosted GGUI and a server you run yourself speak the same protocol — what moves across unchanged, and the few things that differ.","body":"---\ntitle: Portability\ndescription: Hosted GGUI and a server you run yourself speak the same protocol — what moves across unchanged, and the few things that differ.\n---\n\nHosted GGUI runs the same protocol you can run yourself. There is no hosted-only dialect, no proprietary client library, and no lock-in step you would have to unwind later. If hosted GGUI stops suiting you, you point your agent at your own server and keep going.\n\n## What moves unchanged\n\n- **Your agent.** It talks to an MCP endpoint. Change the URL and the credential; nothing else. There is [no client SDK wrapper](/quickstart/hosted-agent/) to swap out, because your agent never imported one.\n- **Your contracts.** `DataContract` is protocol, not product. The same `propsSpec` / `actionSpec` / `contextSpec` shapes are accepted by both sides, and the [conformance kit](/protocol/conformance/) is what settles any disagreement.\n- **Your blueprints.** Cached blueprints are exportable artefacts. `ggui export-pool` writes a pool you load on another server with `ggui serve --seed-pool <dir>`, so the work of generating them travels with you.\n- **Your gadgets.** Published gadgets are content-addressed, signed bundles installed by manifest. They resolve the same way on either side.\n\n## What differs\n\n|                       | Hosted                                                                                                                                                         | Your own server                                                            |\n| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |\n| **Model access**      | GGUI's credit pool, or your own provider key                                                                                                                   | Always your own provider key                                               |\n| **Billing**           | Pay-as-you-go from a prepaid balance at published rates per render kind; a welcome credit to start, while it is available ([pricing](https://ggui.ai/pricing)) | Your provider's bill, directly                                             |\n| **Registry endpoint** | The hosted marketplace                                                                                                                                         | The hosted marketplace, or [your own registry](/sdk/self-hosted-registry/) |\n\nCredentials do not carry over in either direction: a `ggui_user_*` connector key is meaningless to a server you run, and a self-hosted key is meaningless to `mcp.ggui.ai`. That is the boundary working correctly, not a migration cost.\n\n## Going self-hosted\n\nStart with the [self-hosted quickstart](/quickstart/self-hosted/) — it runs the whole stack locally with no account. When you are ready to put it somewhere, the [reference deploys](/self-hosted/reference-deploys/) cover the shapes that are known to work."},{"path":"/hosted/rate-limits.md","title":"Rate limits","description":"How GGUI surfaces rate limits — the in-band render refusals on `ggui_render` (`app_rate_limited`, `issuer_rate_limited`), the HTTP 429 + Retry-After contract, and how host SDKs handle backoff.","body":"---\ntitle: Rate limits\ndescription: How GGUI surfaces rate limits — the in-band render refusals on `ggui_render` (`app_rate_limited`, `issuer_rate_limited`), the HTTP 429 + Retry-After contract, and how host SDKs handle backoff.\n---\n\nGGUI rate limiting is operator-configured: a `RateLimiter` seam the deployment wires (or doesn't). This page covers the self-hosted defaults, the two enforcement layers and their wire shapes, and how to layer retry on top of whichever MCP host SDK you're using.\n\n:::note[Hosted GGUI]\nHosted GGUI bills renders separately from rate limiting: every render is charged to the app owner's prepaid balance at its per-kind rate (a BYOK render for its platform lane), and a balance at or below zero refuses `ggui_render` with a `402` before any LLM call, independent of the per-minute rate limit below. Rates and the welcome credit live on [ggui.ai/pricing](https://ggui.ai/pricing); the mechanics are in [Credits, billing & BYOK](/hosted/billing/#one-product-prepaid-credit).\n\nThe per-minute limit itself is set by the app owner, per app, on the app's **Settings** tab in [the GGUI console](/hosted/console/) (`console.ggui.ai/apps/<appId>/settings`) — a \"renders per minute\" field, empty means unlimited. It's enforced per API key (one fixed-window bucket per app + credential), so a leaked key is bounded without capping the whole app. An agent learns the active value from `ggui_discover`'s `app.rateLimitPerMinute` field on connect; a call over the limit is refused in-band with the `app_rate_limited` refusal documented below. Federation-enabled apps also share a per-issuer cap: hosted GGUI bounds an issuing tenant's total render attempts across every app it provisions in a one-minute window, checked before the per-app limit, and that denial comes back as `issuer_rate_limited`; an app with no federation issuer never meets it. The wire contracts below are independent of the numbers.\n:::\n\n## Self-hosted defaults\n\n- Default (strict) `ggui serve` wires **no** generation limiter — `ggui_render` is unlimited for paired callers.\n- `ggui serve --public-demo` binds a per-remote-IP fixed-window limiter to `ggui_render`: **30 generations / 10 minutes / IP** (operator-pays posture for public demos).\n- Library users wire their own `RateLimiter` into the render handler deps — the seam (`RateLimiter`, `RateLimitDecision`) lives in `@ggui-ai/mcp-server-core`. A denial is never thrown: the handler projects it as a refusal described below — `app_rate_limited` for the app's own cap, or `issuer_rate_limited` when the limiter reports (`RateLimitDecision.scope: 'issuer'`) that the cap on the issuing identity behind the credential bound.\n\n## Two enforcement layers\n\n### Tool-level: `ggui_render` refuses in-band\n\nWhen a rate limiter is wired into the render handler, a limited `ggui_render` call is **refused** — not a thrown tool error, not an HTTP 429. The tool result is a render-gate refusal like any other — `isError: true`, `structuredContent.outcome: 'refused'`, `refusal.code: 'app_rate_limited'` (or `issuer_rate_limited` when the issuing identity's cap bound), `retry: 'later'` — with the wait in milliseconds carried by the refusal's `message` and `fix`. The handshake stays intact, so the retry is the same call with the same `handshakeId` after the stated wait. Handle it in your agent loop the way you handle every render-gate refusal: read `refusal.retry`, wait, re-call.\n\n### HTTP-level: 429 on auth/pairing endpoints\n\nThe pairing/login routes enforce limits at the HTTP transport layer. Every limited request returns:\n\n| Field                | Value                                                                                          |\n| -------------------- | ---------------------------------------------------------------------------------------------- |\n| HTTP status          | `429`                                                                                          |\n| `Retry-After` header | Seconds before the next attempt is permitted. Optional — absent means use exponential backoff. |\n| Body                 | JSON `{ \"error\": { \"code\": \"rate_limited\", \"message\": \"...\", \"retryAfter\": <seconds> } }`.     |\n\n`Retry-After` is the authoritative signal. When present, honor it verbatim — the server has already computed the appropriate wait. The `retryAfter` field in the body mirrors the header for convenience when only the body is observable (e.g. some transport wrappers).\n\n:::note[Hosted 429s are the ingress, not a render cap]\nThe one `429` a hosted caller can meet is the ingress's per-IP flood backstop: plain HTTP, no JSON-RPC error body (the request never reaches GGUI), and `Retry-After` governs the wait. The render caps never travel as an HTTP status — on `tools/call` they are returned refusals: `app_rate_limited` for the per-app cap, `issuer_rate_limited` for the per-issuer cap, both `retry: 'later'` with the wait inside the refusal and the handshake intact. The auth and pairing endpoints are REST, not JSON-RPC — their `429` body keeps the `{ \"error\": { \"code\": \"rate_limited\" } }` shape in the table. On a self-hosted server nothing maps a render-gate denial to a `429`: it is a refusal inside the tool result, never a thrown error — and default `ggui serve` wires no limiter, so nothing fires there.\n:::\n\n## Retry is the host SDK's job\n\nGGUI has no first-party client SDK to wrap retries — your MCP host owns that loop. The pattern is the same regardless of host: catch the 429, read `Retry-After`, sleep, retry, cap attempts. For `ggui_render`, additionally check the tool result for a refusal — `isError: true` with `structuredContent.outcome: 'refused'` and a `refusal.code` of `'app_rate_limited'` or `'issuer_rate_limited'` — and back off for the wait its `message` states.\n\n### Claude Agent SDK\n\nThe Claude Agent SDK's `query()` already retries transient transport errors (including 429) using the standard Anthropic SDK retry config. You generally don't need to do anything — bursts within the retry window never surface to your code. To tune, pass `maxRetries` through the SDK's options. See [Examples → Claude Agent](/examples/claude-agent/) for a runnable scaffold.\n\n### `@modelcontextprotocol/sdk` (generic MCP)\n\nThe official MCP SDK throws on HTTP errors without retrying. Wrap `callTool` (or whichever method you invoke) yourself:\n\n```typescript\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\n\nconst client = new Client({ name: \"my-agent\", version: \"1.0.0\" });\nawait client.connect(\n  new StreamableHTTPClientTransport(new URL(\"http://127.0.0.1:6781/mcp\"), {\n    requestInit: { headers: { Authorization: \"Bearer dev\" } },\n  })\n);\n\nasync function callWithRetry<T>(\n  fn: () => Promise<T>,\n  { maxRetries = 3, baseDelayMs = 1000, maxDelayMs = 30000 } = {}\n): Promise<T> {\n  for (let attempt = 0; attempt <= maxRetries; attempt++) {\n    try {\n      return await fn();\n    } catch (err) {\n      // The MCP SDK surfaces HTTP errors with status + headers attached.\n      const status = (err as { status?: number }).status;\n      if (status !== 429 || attempt === maxRetries) throw err;\n\n      const retryAfter =\n        Number((err as { headers?: Record<string, string> }).headers?.[\"retry-after\"]) || undefined;\n      const waitMs =\n        retryAfter != null ? retryAfter * 1000 : Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);\n\n      await new Promise((r) => setTimeout(r, waitMs));\n    }\n  }\n  throw new Error(\"unreachable\");\n}\n\nconst result = await callWithRetry(() =>\n  client.callTool({\n    name: \"ggui_handshake\",\n    arguments: {/* ... */},\n  })\n);\n```\n\nTune `maxRetries` per workload: lower on interactive (user-blocking) paths so failures bubble up fast; raise on background batch paths where backoff is cheaper than re-queuing. Note that a rate-limited `ggui_render` on a `--public-demo` server does NOT surface as an HTTP error — it resolves as a refusal (`isError: true`, `structuredContent.outcome: 'refused'`, `refusal.code: 'app_rate_limited'`); check the result before treating the call as a success.\n\n## Raw HTTP\n\nIf you're hitting the server directly without an MCP SDK, implement the same loop against `fetch`:\n\n1. Read the `Retry-After` header on every 429 — a hosted 429 is the ingress backstop and carries no body. For a `ggui_render` refusal the wait is inside the refusal itself (`message` / `fix`, in milliseconds).\n2. If present, sleep that many seconds, then retry.\n3. If absent, sleep `min(baseDelay * 2^attempt, maxDelay)`, then retry.\n4. Cap retries (3–5 is reasonable for interactive workloads, more for batch).\n5. Stop retrying on non-429 4xx (those won't resolve with backoff).\n6. Treat a `503` whose JSON-RPC body carries `-32014` as the saturated generation queue — hosted GGUI always sends `Retry-After` on it; wait exactly that long. The server, not you, is the limit.\n\nThe [generic MCP example](/examples/generic-mcp/) walks through raw-HTTP usage end-to-end.\n\n## See also\n\n- [Examples → Claude Agent](/examples/claude-agent/) — runnable scaffold with the host SDK's native retry.\n- [Cookbook → Error handling](/cookbook/error-handling/) — retry, surfacing, and dead-letter patterns.\n- [Troubleshooting](/troubleshooting/) — common error patterns.\n- [MCP Protocol](/api/mcp-protocol/) — full JSON-RPC method reference."},{"path":"/hosted/trust.md","title":"Trust & security","description":"What hosted GGUI stores, which of it reaches a language model, how keys and the render iframe are protected, and how to delete an account.","body":"---\ntitle: Trust & security\ndescription: What hosted GGUI stores, which of it reaches a language model, how keys and the render iframe are protected, and how to delete an account.\n---\n\nHosted GGUI sits between your agent and your user, so it necessarily handles both sides' data. This page says what it holds, for how long, and which parts a language model sees. Where the honest answer is a limit rather than a guarantee — the host controls the sandbox, a signature is not an audit, one contract field can carry data into the prompt — it says that instead of reassuring you.\n\n## Your data in hosted GGUI\n\nA render is one generated interface. Everything below is scoped to a render, an app, or your account.\n\n| Data                                                                                          | Stored where                                                                                                                    | Reaches a language model?                                                                |\n| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |\n| The intent and contract your agent sends                                                      | The render row in DynamoDB, and — if the render becomes a reusable blueprint — a blueprint row                                  | **Yes.** This is the generation prompt                                                   |\n| The generated component code                                                                  | Inline on the render row; durable blueprints go to S3 keyed by content hash; a copy of the TSX lands in a per-user render cache | It _is_ the model's output                                                               |\n| The props your agent renders with                                                             | On the render row, next to the code                                                                                             | **No.** Props values are not part of the generation prompt                               |\n| What your user does in the UI — action payloads and the context snapshot taken at that moment | Buffered on the render row until the agent consumes them, and appended to a per-render event log                                | **Not by ggui.** They are handed back to your agent, which is itself a model — see below |\n| The end-user identity your auth adapter supplies                                              | An `endUserIdentity` column on the render row                                                                                   | No                                                                                       |\n| Connector keys (`ggui_user_*`)                                                                | A SHA-256 hash plus an 8-character prefix. Plaintext is returned once at mint time and never written                            | No                                                                                       |\n| Your own provider keys (BYOK)                                                                 | KMS ciphertext, decrypted into memory for the duration of one call                                                              | No — the key authenticates the provider call, it is never prompt content                 |\n\n### What generation actually sees\n\nThe generation prompt is built from your agent's intent, the contract's **schemas** (field names, JSON-Schema types, and any `default` / `example` values you declare on them — so treat contract examples as prompt content), the variance signals, the rendering context the host reports (device, shell, viewport), the gadget catalogue, and GGUI's own design-system documentation. Runtime props are carried alongside the generation input and deliberately not spread into it.\n\nBlueprint matching, the semantic cache for generative UI, has two tiers. An exact contract-key match is deterministic and calls no model at all. When that misses, candidates from your app's own pool are retrieved by embedding similarity (your intent against each blueprint's intent and contract), computed with Amazon's Titan Text Embeddings V2 model through Amazon Bedrock. TypeSafe AI's Jev model then chooses among them, or decides that none fits. If Jev does not answer within 1.5 seconds, returns an error, or is not confident in the candidate it picked, GGUI's LLM judge chooses instead. Whichever one decides is shown your intent, each candidate's stored intent (truncated to 280 characters), and **summaries** of the contracts (`name:type` pairs), never props or event payloads.\n\n`ggui_amend` performs no generation. It swaps props and pushes them down the live channel; no model is involved. `ggui_update` is the same swap plus a new history card (it also mints no generation, but does advance the epoch).\n\n:::caution[Two contract fields carry values into the prompt]\n`variance` is meant for design-shaping signals only — persona, aesthetic, mood — and `variance.context` is serialized verbatim into the generation prompt. Separately, the `default` and `example` values you declare on a spec entry are rendered into the prompt as comments, so the model sees the exact sample data you wrote. Neither is a bug: examples exist to show the model the shape it must handle. But the rule that per-user runtime data belongs in `props` and `contextSpec` is documented guidance to your agent, not something the server structurally enforces. Keep real user data out of `variance`, `default`, and `example` — put it in `props`, which never enters the prompt.\n:::\n\n### The event loop is a model, by design\n\nWhen your user submits a form, that payload travels back to your agent through `ggui_consume`. Your agent is a language model. That is the entire point of the protocol — the user's answer becomes structured input to the next turn — but it means \"does my user's form input reach an LLM?\" has the honest answer **yes, yours**. GGUI's own generation and matching models never see it.\n\n### What changes with your own key\n\nIf you store a provider key and the app is configured to use it, the generation call is made with your key, so the prompt goes to your provider account under your agreement with that provider rather than through GGUI's pool. Storing a key alone does not do this — the app's generation billing has to select it, via the **Generation billing** card on the app's Keys or Settings tab in the console. See [Credits, billing & BYOK](/hosted/billing/#bring-your-own-key-byok) for that gate and what it does to billing.\n\n## Keys\n\n**Connector keys.** A key is 32 bytes of randomness prefixed `ggui_user_`. What gets stored is a SHA-256 hash of the whole string plus the first 8 characters, which is what the console lists. The plaintext is handed to you exactly once and is not recoverable afterwards — if you lose it, mint a new one. Revocation is a soft flag: the row survives for audit and later requests with that key are rejected. Full mechanics in [Connector keys](/hosted/connector-keys/).\n\n**Provider keys.** Keys you bring are validated, then encrypted with AWS KMS under an encryption context that binds them to their scope — `{userId, provider}` for an account-wide key, `{appId, provider}` for a per-app one. The plaintext is never persisted — only the KMS ciphertext, a hash, and the last four characters; decryption happens in memory for the call. Because the encryption context is cryptographically bound to the ciphertext, a key encrypted for one scope fails to decrypt in another rather than silently working. The console only ever displays the last four characters.\n\n**Publisher keys.** Signing keys for the marketplace are Ed25519 and stay on your machine; the registry holds the public half. See [`ggui keys register`](/cli/keys-register/).\n\n## Sandboxing\n\nGenerated code is code a model wrote. It runs in an iframe, and it never runs with access to the host document.\n\nWhen GGUI mounts the iframe itself, the sandbox grants `allow-scripts allow-forms` and deliberately withholds `allow-same-origin`, so the frame cannot read the page that embeds it. Camera, microphone, geolocation, and clipboard-write are only enabled when the render's contract asked for them. Component bundles are content-addressed and served with their SHA-256 alongside; gadgets load behind an SRI integrity gate at iframe boot.\n\nThree honest limits:\n\n- **In a third-party host, the host owns the sandbox.** When your interface renders inside Claude Desktop or claude.ai, that host sets the iframe's sandbox tokens and Content-Security-Policy. GGUI declares which origins the frame needs to reach; enforcement is the host's, and that surface is opaque to ggui.\n- **Marketplace gadgets are signed, not audited.** Signature verification proves a bundle came from the author who published it and has not been altered since. It says nothing about what the bundle does. Public gadgets verify through sigstore, private ones through a pinned Ed25519 key — the [marketplace](/hosted/marketplace/) page has both postures.\n- **The integrity gate is only as strong as the descriptor.** A gadget installed from the registry carries a SHA-384 SRI hash that the runtime enforces. A hand-authored gadget entry with no `bundleSri` falls back to an integrity-less dynamic import, and installing a legacy artifact whose version has no pinned public key warns and continues unless you pass `--strict`. See [Gadgets](/sdk/gadgets/).\n\nThe channel model and where the SRI gate sits are in the [architecture overview](/architecture/overview/).\n\n## Operator access\n\nA small operator group exists for support and monitoring, and it can read render records — the contract, the generated code, and event payloads — across accounts, through the same access-controlled admin surface that runs the service. Three facts bound it:\n\n- **Membership is manual.** The group has no self-join path; an operator is added by an infrastructure command, not a sign-up flow.\n- **It cannot read provider keys.** The BYOK key table deliberately carries no operator grant — an operator never sees your provider keys, even in encrypted form.\n- **Every read is individually audit-logged.** Operator reads of render data go through a single audited query — the underlying models carry no direct operator grant, so a read that skips the log is structurally impossible. Each entry records the operator's identity and the target keys, and lands in a dedicated log group retained for two years.\n\n## Deletion & retention\n\n**Renders and their event logs.** A render (the request, including the data sent for display, the data contract, the generated interface code, and its interaction events) is kept until your account is deleted or an operator removes it at your request. There is no automatic expiry and no per-render time limit, so reopening an old card from chat history always finds it, which is the point of the protocol's rehydration story. Deleting a single app does not delete its render records.\n\n**Blueprints.** The word covers two different things:\n\n- A **saved blueprint** is one kept in an app's blueprint library under a name. It is deleted with the app, and with your account.\n- A **reuse-cache blueprint** is derived automatically from a render, so that similar requests can reuse a proven interface. It holds the request's intent, the data contract, and the generated interface code, never the data displayed or interaction events. It carries no expiry and remains after your account is deleted unless an operator removes it. Its code is stored by content hash and can be shared by apps that generated the same component, so a code file is reclaimed only by an operator-invoked purge (a takedown, or collection of code no longer referenced by any blueprint), never on a schedule.\n\n**Backups.** Deleted data in our database tables can remain in point-in-time backups for up to 35 days. Generated code files and the reuse-cache index are not backed up. The binding list of what is kept and for how long, including short-lived records, is §7 of the [privacy policy](https://ggui.ai/privacy).\n\n**Your account.** `/account` in the console has a type-to-confirm **Delete account** control. It is blockers-first: the request is refused, with the reason stated and nothing deleted, if you own a platform-managed app or if you are the last owner of an org that still has other members. If it is accepted, the account is frozen immediately — global sign-out plus disable — and an asynchronous worker then walks the deletion in order: org memberships, then each app with its keys, saved and installed blueprints, and renders, then your connector keys, provider keys, render cache, credit balance, renders, coupon attribution, and status subscription, then your user row. Cognito sign-in is deleted last, and only after a verification pass re-runs every finder query and finds nothing left. If anything remains, the erasure is marked failed and sign-in is deliberately preserved so the leftover can be traced.\n\nTwo things survive on purpose, both stated in the console's own confirmation: billing records required for accounting are kept with your personal details scrubbed rather than deleted, and CLI session tokens are left to their own expiry (they are hashed and time-bounded) instead of being swept.\n\nOne gap worth naming: if you were an **end user** of somebody else's hosted app, your identity data on their render rows is not reachable by your own account erasure. It persists as long as those renders do — until the app owner deletes their account or an operator removes the rows.\n\n## Policies and status\n\n- [Privacy policy](https://ggui.ai/privacy) — the binding statement, including subprocessors and analytics.\n- [Terms of service](https://ggui.ai/terms)\n- [status.ggui.ai](https://status.ggui.ai) — live service status.\n\nFor anything this page does not answer, the privacy policy lists a contact address."},{"path":"/how-it-works.md","title":"How GGUI works","description":"The five moments of a GGUI exchange — handshake, render, interact, consume, reopen — explained end-to-end in five minutes.","body":"---\ntitle: How GGUI works\ndescription: The five moments of a GGUI exchange — handshake, render, interact, consume, reopen — explained end-to-end in five minutes.\n---\n\nA walk-through for agent developers. You'll come out of this with a working mental model of what happens between the moment your agent calls `ggui_handshake` and the moment the user submits the form.\n\nFive minutes. No setup required — this is conceptual.\n\n## The five moments\n\nEvery GGUI exchange is the same five moments, in order:\n\n```\n1. HANDSHAKE   Post a draft contract; the server routes a suggestion\n2. RENDER      Accept or override; the server mints an MCP-Apps resource\n3. INTERACT    The host mounts it; the user fills the UI and submits\n4. CONSUME     Drain the user's gestures off a render-scoped pipe\n5. REOPEN      The card is durable — scroll back and it still works\n```\n\nThe rest of this page expands those five moments into a story.\n\n## 1. Handshake — the wire surface is negotiated\n\nYour agent's first call is `ggui_handshake` — the server runs blueprint search in its reuse layer, the semantic cache for generative UI, in parallel with contract validation, and returns a routed suggestion. (These are MCP tool calls the LLM emits; there is no client SDK — the shapes below are the tool input → output.)\n\n```ts\n// ggui_handshake tool — input:\nggui_handshake({\n  intent: \"collect feedback after a support chat\",\n  blueprintDraft: {\n    contract: {/* propsSpec, actionSpec, ... */},\n  },\n});\n// → { handshakeId, action, suggestion }\n```\n\nThe returned `suggestion.origin` is `cache` (existing blueprint matched), `agent` (gen against the draft), or `synth` (gen against an amended draft). No UI is generated yet — the agent commits next, on render.\n\nEach render is independent: each `handshake → render` pair mints a fresh **GguiSession** — the protocol's unit for one rendered UI — keyed by `sessionId`. There is no conversation-level session object; conversation-scoped grouping (sibling renders inside the same chat) flows through the `_meta[\"ai.ggui/host-session\"]` slice — captured ONCE at creation.\n\n→ See [`ggui_handshake`](/api/mcp-protocol/) for the wire shape.\n\n## 2. Render — the UI gets generated (or matched)\n\nNow the agent commits against the prior handshake's suggestion — `props` is required; omit `override` to accept the suggestion as-is:\n\n```ts\n// ggui_render tool — props required; omitting `override` accepts\n// the handshake suggestion:\nggui_render({\n  handshakeId,\n  props: { question: \"How did the session go?\" },\n  // or re-aim: override: { contract: {...} } / { variance: {...} }\n});\n// → { outcome: 'rendered' | 'failed' | 'refused', ... }\n//   read `outcome` first — sessionId + resourceUri ride a rendered result\n```\n\nServer-side, materialisation runs one of two paths — the path was already chosen at handshake time, render just executes it:\n\n1. **Cache delivery** (`suggestion.origin === 'cache'`). A matching blueprint was found during handshake; render serves the cached component — zero generation calls.\n2. **Fresh generation** (`origin === 'agent'` or `'synth'`). The server runs the LLM-driven UI generator (`@ggui-ai/ui-gen`) — plan → impl → check → derive. The output is a TSX component compiled to JS, plus a typed **contract** describing the actions the user can take and the data they can submit. It takes tens of seconds.\n\nEither way, any gadgets the component imports (Leaflet, Stripe, Calendar, …) resolve from the app's declared gadget set (stdlib floor + `ggui.json#app.gadgets`) and load SRI-verified at iframe boot.\n\nEvery response leads with `outcome`. On a `rendered` one the agent gets back a `sessionId` (globally unique UUID for the delivered render) and a `resourceUri` (`ui://ggui/render/<id>`). The render is an MCP-Apps resource — there is no clickable URL the agent forwards; a host mounts the resource. The other two outcomes carry no mount: `failed` means generation ran and produced nothing, and `refused` means the deployment declined before doing any work, so it carries only a `refusal` and leaves the handshake valid.\n\n→ See [`ggui_render`](/api/mcp-protocol/) for the wire shape.\n\n## 3. Interact — the user fills the UI\n\nA host mounts the render — your app via `<AppRenderer>`, or an MCP-Apps host like claude.ai inline. The renderer:\n\n1. Hits the **bootstrap channel** — fetches the compiled component bundle (SRI-verified)\n2. Mounts the component in an iframe with the props the agent rendered\n3. Connects the **live channel** — a WebSocket subscription scoped to this render\n4. When the user submits, the component dispatches an `ActionEnvelope` like `{ type: \"data:submit\", payload: {...} }`. The server validates the payload against the contract's `actionSpec`.\n\nThe renderer is **stateless** between page loads — props come from the server, state comes from the user, and the server is the source of truth for the render's state.\n\n→ See [Envelopes](/protocol/envelopes/) for the live-channel wire reference.\n\n## 4. Consume — the action lands back with the agent\n\nActions are agent-routed. The server queues every gesture on a render-scoped pipe; the agent drains it by calling `ggui_consume` (long-poll, keyed by `sessionId`):\n\n```ts\n// ggui_consume tool (long-poll) — returns { events, status }:\nconst { events, status } = ggui_consume({ sessionId, timeout: 25 });\nfor (const event of events) {\n  if (event.intent === \"submit_feedback\") {\n    await processFeedback(event.actionData);\n  }\n}\n```\n\nEach row is a `ConsumeEventEntry`: `{ type: 'action', sessionId, intent, actionData, uiContext, actionId, firedAt }`. `intent` is the action key from the contract's `actionSpec`; `actionData` is the typed payload (validated against `actionSpec[intent].schema`). Exit the loop when you have the events you need — that is the real terminal condition. `status` is the other exit: it stays `'active'` until the render's TTL elapses, at which point `'expired'` means no more events will arrive. On hosted GGUI renders are retained indefinitely by default, so `'expired'` is a self-hosted concern. Do not write a loop that waits for it.\n\nAn `actionSpec` entry may carry a `nextStep: '<toolName>'` hint naming one of the contract's `agentCapabilities.tools` — an **advisory** hint for the agent's planner. Implementations MUST treat it as advisory; the agent owns the call decision. Agent-less `ggui serve` deployments take the same path: events queue on the consume buffer until an agent attaches and drains them — the server never invokes a tool on the user's behalf. There is no second routing model.\n\nWhen the agent wants to refresh the visible card in response to an event (e.g. show a confirmation, splice in new data), it calls `ggui_amend` (keyed by `sessionId`, `kind: 'replace' | 'merge'`) — the iframe receives the new props on the live channel without a fresh `ggui_render`. Then loop back to `ggui_consume`. Rule of thumb: if your reaction ran a domain tool that changed what the card displays, call `ggui_amend` _before_ re-calling `ggui_consume` — skipping it is the most common wire-compliance bug. Use `ggui_update` instead when the reaction is a state milestone worth minting as a new history card.\n\n→ See [`ggui_consume`](/api/mcp-protocol/) and the `ConsumeEventEntry` row shape on the same page.\n\n## 5. Reopen — the card survives the conversation\n\nA GGUI render is not a disposable popup. It is a durable record addressed by a `ui://` locator, and that locator keeps working after your agent has moved on.\n\nScroll back in the transcript, reload the page, or come back tomorrow: the host reads the same locator with `resources/read` and gets the view again. If the live render row has been evicted, the read re-mints the view — from the durable identity record when the deployment keeps one, or from the cached blueprint named by the locator's second segment, which returns the original card with its default props rather than a typed failure.\n\nTwo consequences worth designing around:\n\n- **There is no close ceremony**, because there is nothing to close. When an exchange is over, your agent simply stops addressing that `sessionId`.\n- **`ggui_update` writes history.** Each update mints a new epoch-numbered record and pins the previous one — old cards freeze rather than silently repainting with newer state, so the transcript stays an honest record of what the user actually saw. `ggui_amend` is the opposite move: it repaints the card in place and the history number does not advance.\n\nRetention is a deployment choice. Hosted GGUI retains renders indefinitely by default, so reopening an old card is the expected path, not a lucky one. There is no per-render time limit; a self-hosted server applies its own default.\n\n→ See [Render locator grammar](/api/mcp-apps/#render-locator-grammar) for the URI shape, including the `#N` epoch pin.\n\n## What you didn't have to do\n\nNotice what your agent code did _not_ have to handle:\n\n- **No UI authoring.** The component code was generated or matched from cache.\n- **No WebSocket plumbing.** The renderer connects to the live channel on its own; you didn't open a socket.\n- **No state management.** The server holds render state. You called `ggui_consume` and got events.\n- **No SDK lock-in.** Everything above is plain MCP tool calls — works from any MCP client.\n\nThat's the protocol. The OSS `ggui serve` running locally (`ws://127.0.0.1:6781/ws`) is the reference implementation; hosted GGUI at `https://mcp.ggui.ai` (`wss://mcp.ggui.ai/ws`) is the managed one. Both speak the same wire.\n\n## Next\n\n- **See one traced end to end** — [Showcase: a real turn](/protocol/showcase-trimly/) walks these five moments as an actual recorded exchange with a live ggui-rendering agent, frame by frame off the wire.\n- **Build something** — [build an agent on hosted GGUI](/quickstart/hosted-agent/) against `https://mcp.ggui.ai` (no infrastructure), or the [self-hosted quickstart](/quickstart/self-hosted/) (local)\n- **See the wire** — [MCP Protocol](/api/mcp-protocol/), [Envelopes](/protocol/envelopes/), [WebSocket](/api/websocket-protocol/)\n- **Look up a term** — [Glossary](/glossary/)\n- **Look at example agents** — [Claude](/examples/claude-agent/), [OpenAI](/examples/openai-agent/), [Gemini](/examples/gemini-agent/), [raw MCP](/examples/generic-mcp/)\n- **Already shipped a SaaS?** — [Agentic App Builders](/agentic-app-builders/) covers the (in-design) path to make an existing app agent-drivable."},{"path":"/index.md","title":"Introduction","description":"GGUI is an open, MCP-native protocol for AI agents to render interactive UI on demand. Public preview — hosted GGUI at mcp.ggui.ai via the GGUI console, or self-hosted via `ggui serve`.","body":"---\ntitle: Introduction\ndescription: GGUI is an open, MCP-native protocol for AI agents to render interactive UI on demand. Public preview — hosted GGUI at mcp.ggui.ai via the GGUI console, or self-hosted via `ggui serve`.\n---\n\n**GGUI** is an open protocol that lets AI agents render interactive UI on the fly. Your agent describes what it needs in natural language; GGUI compiles a typed component and returns it as an MCP-Apps resource (`ui://ggui/render/<sessionId>`) that your host mounts. The UI reports back typed events the next time your agent calls `ggui_consume`.\n\nUse **hosted GGUI** at `mcp.ggui.ai` — sign in at [the GGUI console](https://console.ggui.ai) and skip the infrastructure in minutes. Or run the whole protocol yourself with **`ggui serve`** — no account, no cloud, no API key.\n\n<LinkButton href=\"/quickstart/claude-desktop/\">Get UI in Claude Desktop — 3 minutes</LinkButton>\n<LinkButton href=\"/quickstart/hosted-agent/\" variant=\"secondary\">Build an agent on hosted GGUI</LinkButton>\n<LinkButton href=\"/quickstart/self-hosted/\" variant=\"secondary\">Self-hosted quickstart</LinkButton>\n\n:::note[Public preview]\nPre-1.0 draft protocol (`draft-2026-09-10`) — may change. Hosted GGUI is live at [console.ggui.ai](https://console.ggui.ai); self-hosted is fully available too.\n:::\n\n## Choose your path\n\n<CardGrid>\n  <Card title=\"Claude Desktop User\">\n    No code, no terminal. [Use GGUI in Claude Desktop](/quickstart/claude-desktop/) connects your chat to hosted GGUI in about three minutes, then the [Todos playground](/hosted/playgrounds/todos/) — one more connector to add — gives you a real agent-driven interface to try it on. [Other MCP hosts](/clients/connect-other-hosts/) use the same endpoint with their own config shape.\n  </Card>\n  <Card title=\"Agent Builder\">\n    Wire GGUI into an MCP server. Start with the [hosted quickstart](/quickstart/hosted-agent/) (5 min, no infrastructure), then the [MCP protocol reference](/api/mcp-protocol/), the [Cookbook](/cookbook/feedback-form/), or a worked [example agent](/examples/claude-agent/). Browse shareable gadgets and blueprints at [hub.ggui.ai](https://hub.ggui.ai).\n  </Card>\n  <Card title=\"Operator\">\n    Self-host the stack. [`ggui serve`](/cli/serve/) is the local deployment guide, [reference deploys](/self-hosted/reference-deploys/) cover Docker, Fly.io, and Render, and the [Self-hosted Registry](/sdk/self-hosted-registry/) is the artifact layer for private gadgets and blueprints.\n  </Card>\n  <Card title=\"Agentic App Builder\">\n    Ship a SaaS or webapp and want agents to drive it without rewriting the frontend? See [Agentic App Builders](/agentic-app-builders/).\n  </Card>\n</CardGrid>\n\nReading these docs as an LLM? Every page is also raw markdown at the same slug — start at [`/llms.txt`](/llms.txt) (index, per the [llms.txt convention](https://llmstxt.org/)), `/llms-full.txt` (whole site, one file), or `/llms-small.txt` (compact). The [LLM-agent track](/agents/) lists the full machine-readable surface.\n\n## How GGUI works\n\nA typical exchange is five moments — **handshake → render → interact → consume → reopen**. See [How GGUI works](/how-it-works/) for the full walk-through with code, or [a real turn, traced end to end](/protocol/showcase-trimly/) against the production demo agent.\n\n## Key concepts\n\nA few terms recur across the docs:\n\n- **GguiSession** — one rendered UI, minted by `ggui_render` (*render* is the verb; the object it creates is a GguiSession). Each GguiSession carries a stable `sessionId`.\n- **Contract** — the typed agreement between agent and renderer for one GguiSession.\n- **Tool** — an agent-side action (`ggui_render`, `ggui_consume`, …) — the MCP surface.\n- **Gadget** — a renderer-side capability (Leaflet map, Stripe Checkout, …) the LLM can compose with.\n- **Blueprint** — a cached recipe — a UI promoted from one-shot to \"use this exact screen next time.\"\n\n→ [Glossary](/glossary/) for everything else.\n\n:::note[GGUI vs guuey]\n**GGUI** (this site) is the open protocol — source at [github.com/ggui-ai/ggui](https://github.com/ggui-ai/ggui). **guuey** is a separate SaaS agent-hosting platform built on the same protocol. (`mcp.ggui.ai` is GGUI's own hosted deployment, live today — sign in at [console.ggui.ai](https://console.ggui.ai).)\n:::"},{"path":"/protocol/bootstrap-handshake.md","title":"Bootstrap handshake","description":"How a host mounts the GGUI renderer iframe, the postMessage contract that crosses that boundary, and the canonical bootstrap failure modes.","body":"---\ntitle: Bootstrap handshake\ndescription: How a host mounts the GGUI renderer iframe, the postMessage contract that crosses that boundary, and the canonical bootstrap failure modes.\n---\n\nA GGUI render ships as an [MCP App](https://modelcontextprotocol.io/) — a `ResourceContents` blob whose `text` is a thin-shell HTML document. When a host mounts that blob in an iframe, the shell loads the `@ggui-ai/iframe-runtime` bundle, which opens a live-channel WebSocket and starts rendering. This page is the handshake spec across the iframe boundary.\n\n> **Audience:** protocol implementers and third-party MCP host builders. If you're already on `@ggui-ai/mcp-apps-react`, the web consumer surface ([`useMcpAppsChat`](/host-helpers/react/) from `@ggui-ai/mcp-apps-react/chat-helpers` + `<AppRenderer>` from `@mcp-ui/client`) wraps everything below — React Native's equivalent host is `<McpAppIframe>`. The protocol underneath those wrappers is what's documented here.\n\nThe `ProtocolError` union and the full `ObservabilityEvent` catalog ship as exported types in [`@ggui-ai/iframe-runtime`](https://github.com/ggui-ai/ggui/tree/main/packages/iframe-runtime) (re-exported through `@ggui-ai/mcp-apps-react` so host apps need no direct renderer import); version-handshake details live in the [WebSocket Protocol reference](/api/websocket-protocol/). This page is the focused handshake spec.\n\n---\n\n## Two postures\n\nThere are two ways the bootstrap slice reaches the renderer, and which one you implement depends on the server you are talking to. Mixing them up no longer strands a view, but it costs a round trip: see [the read-plane door](#the-read-plane-door).\n\n| Posture               | Who runs it                         | How the slice arrives                                                                                                                                                                                     |\n| --------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Read-plane-only**   | Hosted GGUI (`https://mcp.ggui.ai`) | The host `resources/read`s the per-render locator off `_meta.ui.resourceUri`. The response is a self-contained shell that inlines the slice on `globalThis.__GGUI_META__`. Steps 8–9 below do not happen. |\n| **Inlined bootstrap** | Self-hosted `ggui serve` default    | The tool result carries the slice; the host relays it to the iframe. The full 1–11 flow below.                                                                                                            |\n\nUnder read-plane-only the tool result carries **no** `ai.ggui/render` slice and no `wsToken` — there is no slice for the host to relay, so the read plane is the only path, not a variant of one. See [Tool result shape](/api/mcp-apps/#tool-result-shape) for both result shapes side by side.\n\nA host that handles both is a host that reads `_meta.ui.resourceUri` first and only falls back to relaying a slice it actually found on the result.\n\n### The read-plane door\n\nA host may mount the **declaration-level** static shell (the `_meta.ui.resourceUri` announced on `tools/list`) instead of the per-render locator. That shell carries no slice of its own. It takes the tool result the host forwards via `ui/notifications/tool-result`:\n\n- If the result carries the `ai.ggui/render` slice (inlined bootstrap), the shell mounts from it.\n- If the result carries only the view's identity (read-plane-only), meaning the `ui://ggui/render/…` locator on `structuredContent.resourceUri` or `_meta.ui.resourceUri`, the shell asks the host to `resources/read` that locator. It recovers the envelope the per-render shell inlines and mounts from it. The read goes through the host's own MCP client, so the sandboxed view never fetches the server.\n\nThe door needs two things from the host: it forwards the result, and it answers `resources/read` from the view (see [the methods table](#json-rpc-methods-the-host-responds-to)). If the host cannot proxy the read (it rejects it, or gives no answer within 30 seconds), the shell falls back to waiting for a forwarded result. If no usable result arrives within 30 seconds, whether nothing was forwarded at all or the door missed, the shell shows a failure card and reports `MISSING_META_GGUI_BOOTSTRAP`; after a door miss, the message names the `READ_DOOR_FAILED:` cause. The hosted runtime uses the same 30-second bound and reports the same reason. Reading the per-render locator directly skips the extra read, and it is still the recommended path.\n\nReceipt: [scenario 28](https://github.com/ggui-ai/ggui/blob/main/e2e/wire-scenarios/tests/28-read-plane-door-static-shell.spec.ts) mounts the declaration-level shell against a read-plane-only server and forwards the identity-only result. It asserts that the view paints with exactly one host-proxied `resources/read`.\n\n## The boot flow\n\nBelow is the **inlined-bootstrap** flow in full. Under read-plane-only, step 1 fetches the per-render locator rather than the declaration-level shell, and steps 8–9 are replaced by the shell's own synchronous `globalThis.__GGUI_META__` read. A host that mounts the declaration-level shell anyway keeps step 8, but the result it forwards carries only the locator, and step 9 becomes the [read-plane door](#the-read-plane-door).\n\n```\nhost                                                 renderer (in iframe)\n │                                                              │\n │ 1. fetch ResourceContents from MCP server                    │\n │    (`{contents: [{uri, mimeType:'text/html', text}]}`)       │\n │                                                              │\n │ 2. mount <iframe srcdoc={text} />                            │\n │    (or <iframe src={uri} /> for http(s) URIs)                │\n │ ──────────────────────────────────────────────────────────▶  │\n │                                                              │ 3. <script src={runtimeUrl}> loads\n │                                                              │ 4. runtime evaluates bundle\n │ 5. iframe → host: postMessage 'ggui:renderer-ready'          │\n │ ◀──────────────────────────────────────────────────────────  │\n │ 6. iframe → host: jsonrpc 'ui/initialize'                    │\n │ ◀──────────────────────────────────────────────────────────  │\n │ 7. host → iframe: result w/ hostContext (capabilities only)  │\n │ ──────────────────────────────────────────────────────────▶  │\n │ 8. host → iframe: 'ui/notifications/tool-result' carrying    │\n │    params._meta[\"ai.ggui/render\"] (the bootstrap slice)      │\n │ ──────────────────────────────────────────────────────────▶  │\n │                                                              │ 9. parse _meta[\"ai.ggui/render\"]\n │                                                              │ 10. WS handshake (live channel)\n │                                                              │ 11. subscribe + ack\n │                                                              │\n │ ── steady state: tools/call JSON-RPC, ggui:observe stream ── │\n```\n\nSelf-contained shells — per-render HTML documents that inline JSON before the bundle loads — write the same slice synchronously on `globalThis.__GGUI_META__`; the runtime reads it directly and never waits for step 8. That is what a `resources/read` of a per-render locator returns, and it is the only path on hosted GGUI.\n\nAny step from 3–11 can fail terminally. Failures surface as `postMessage({type: 'ggui:bootstrap-failed', reason, message})` from the iframe; the renderer does not recover. After step 11 (the subscribe ack), failures shift to live-channel `error` frames (e.g. a typed `CONTRACT_VIOLATION` error frame) — see [Envelopes](/protocol/envelopes/).\n\n---\n\n## postMessage contract\n\nFour event types flow from iframe → host. Every host MUST handle the first three; `ggui:lifecycle` handling MAY be a no-op, but hosts MUST tolerate it.\n\n### `ggui:renderer-ready`\n\nEmitted once per render, immediately after the runtime bundle evaluates and its status DOM mounts — before `ui/initialize`. It means the bundle loaded; it does NOT mean the live channel is up. Steady-state liveness is signaled by the WS subscribe ack (and lifecycle state `code-ready`).\n\n```ts\n{ type: 'ggui:renderer-ready', version: string }\n```\n\n`version` is the iframe-runtime bundle version, not the protocol version. Hosts typically log it for support and diagnostics.\n\n### `ggui:bootstrap-failed`\n\nEmitted at most once per render, when any boot step from 3–11 fails terminally. The renderer does NOT recover. The host MUST surface this as a user-visible error — naming the `reason` verbatim is the recommended UX (it gives operators a searchable string).\n\n```ts\n{\n  type: 'ggui:bootstrap-failed',\n  reason: BootstrapFailureReason,  // extensibly-closed union — see below\n  message: string,                  // operator-readable detail\n}\n```\n\n### `ggui:observe`\n\nEmitted multiple times per render, on happy paths and failures alike. Carries telemetry the host MAY render in a RenderInspector-style view. Hosts SHOULD forward these to their own telemetry pipeline; dropping them is allowed but blinds your operators.\n\n```ts\n{ type: 'ggui:observe', event: ObservabilityEvent }\n```\n\n`ObservabilityEvent` is an extensibly-closed union including `schema-version-mismatch`, `subscribe-failed`, and the `channel-transport-*` family. See [the implementer guide](https://github.com/ggui-ai/ggui/blob/main/docs/guides/implementing-ggui-protocol.md#observability) for the full event catalog.\n\n---\n\n## JSON-RPC methods the host responds to\n\nThe iframe-runtime makes JSON-RPC calls on the host over postMessage. Every implementer MUST handle these methods; a row marked **Conditional** binds only the hosts it names:\n\n| Method                          | When                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | Host responds with                                                                                                                                                                                                                                                                                                                                  |\n| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `ui/initialize`                 | Once, before bootstrap (step 6 above). Params: `{ appInfo, appCapabilities, protocolVersion }` per MCP Apps spec.                                                                                                                                                                                                                                                                                                                                                                                          | The spec's `McpUiInitializeResult`: `{ protocolVersion, hostInfo, hostCapabilities, hostContext }`, all four required. It carries **no** bootstrap slice: the slice arrives on the forwarded tool result (step 8) or from the read plane. `hostContext` MAY carry `containerDimensions`, `availableDisplayModes`, `platform`, `deviceCapabilities`. |\n| `tools/call`                    | When generated component code issues a direct `tools/call` (e.g. `ggui_runtime_submit_action`).                                                                                                                                                                                                                                                                                                                                                                                                            | Forward to the MCP server's tool registry; return its response verbatim.                                                                                                                                                                                                                                                                            |\n| `resources/read`                | **Conditional.** The view resolves a `ui://ggui/render/…` locator through the host. This is the [read-plane door](#the-read-plane-door), taken when a declaration-level shell receives a result that carries only the locator. A host that mounts the declaration-level shell against a read-plane-only server MUST answer it, and SHOULD advertise `hostCapabilities.serverResources` (the spec's capability for proxying resource reads). A host that mounts only per-render locators never receives it. | Forward to the MCP server's `resources/read` over the host's own session and return its `ReadResourceResult` (`{ contents: [{ uri, mimeType, text }] }`) verbatim.                                                                                                                                                                                  |\n| `ui/open-link`                  | When generated component code calls a navigation primitive.                                                                                                                                                                                                                                                                                                                                                                                                                                                | `{}` after performing host-appropriate navigation (open in new tab, deep-link, etc.).                                                                                                                                                                                                                                                               |\n| `ui/notifications/size-changed` | Iframe content resized; carries the new height. **Notification** (no response required).                                                                                                                                                                                                                                                                                                                                                                                                                   | n/a — host SHOULD adjust iframe dimensions.                                                                                                                                                                                                                                                                                                         |\n| `ui/message`                    | Component code surfaces a chat-bound natural-language message.                                                                                                                                                                                                                                                                                                                                                                                                                                             | `{}` after delivering to the chat surface.                                                                                                                                                                                                                                                                                                          |\n| `ui/update-model-context`       | Component code mutated a context slot; host forwards to the MCP server (fire-and-forget).                                                                                                                                                                                                                                                                                                                                                                                                                  | `{}`.                                                                                                                                                                                                                                                                                                                                               |\n| `ui/request-display-mode`       | Component code requests an enum change (`inline` / `fullscreen` / `pip`).                                                                                                                                                                                                                                                                                                                                                                                                                                  | `{}` after honoring (or rejecting) the request.                                                                                                                                                                                                                                                                                                     |\n\n**Bootstrap delivery.** Under the inlined-bootstrap posture, after answering `ui/initialize` the host sends a `ui/notifications/tool-result` notification (host → iframe, no response) whose `params._meta[\"ai.ggui/render\"]` carries the bootstrap slice. `params` is a `CallToolResult` per the MCP Apps spec, and the slice is read from its top-level `_meta` only. Under read-plane-only there is no slice on the result. A host that mounted the per-render locator needs no relay, because the self-contained shell inlines the slice on `globalThis.__GGUI_META__`. A host that mounted the declaration-level shell still forwards the identity-only result, and the shell takes the [read-plane door](#the-read-plane-door).\n\nHosts MUST NOT intercept `tools/call` traffic — verbatim forwarding to the MCP server's tool registry is the only correct behavior. Modifying or filtering tool calls breaks the typed-channel contract enforced server-side.\n\nLiveness on the live-channel WebSocket (post-bootstrap) is a separate `type: 'ping'` WebSocket frame, not a postMessage method — see [WebSocket Protocol reference](/api/websocket-protocol/).\n\n---\n\n## `BootstrapFailureReason`\n\nExtensibly-closed union. Hosts MUST handle unknown values gracefully (render the raw string, don't switch-case-throw). The canonical first-party set, grouped by source:\n\n### Parse-time (slice-meta extractor failed)\n\nFailures observed when the slice-meta extractor runs — after the runtime bundle evaluates, before any live-channel attempt.\n\n| Reason                                                   | Cause                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |\n| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `MISSING_TOOL_OUTPUT`                                    | The `ui/notifications/tool-result` notification's `params` was missing or not an object (no usable `CallToolResult` payload). A well-formed `params` without a top-level `_meta` fails as `MISSING_META_GGUI_BOOTSTRAP` instead.                                                                                                                                                                                                                                                                                                       |\n| `MISSING_META_GGUI_BOOTSTRAP` / `BOOTSTRAP_META_MISSING` | `_meta[\"ai.ggui/render\"]` slice absent (synonyms; the error names are legacy labels — the wire key is `ai.ggui/render`). The runtime raises it when no source yields a slice: nothing inline on `globalThis.__GGUI_META__`, no read-plane door resolution, and no slice on a forwarded tool result. The declaration-level static shell raises it too, after the same 30-second wait: when no usable result arrives, or when the [read-plane door](#the-read-plane-door) misses (the message then names the `READ_DOOR_FAILED:` cause). |\n| `MALFORMED_BOOTSTRAP`                                    | Bootstrap token failed structural parse. The declaration-level shell also reports it when the [read-plane door](#the-read-plane-door) reads a resource that carries no envelope (the message starts `MALFORMED_BOOTSTRAP:`). A read the host could not answer is a door miss, not a malformed bootstrap; see `MISSING_META_GGUI_BOOTSTRAP`.                                                                                                                                                                                            |\n| `EXPIRED_BOOTSTRAP`                                      | The slice's live credential had expired and nothing replaced it: the slice has no static content, and its one refresh through the host (`ggui_runtime_refresh_ws_token`) was refused or failed, or the host has no relay to refresh through. With a relay the refresh is tried first (a relay error is retried twice, 1–3 s apart), so unlike the rest of this table it can arrive a few seconds after the extractor ran.                                                                                                              |\n\n### Post-parse orchestration\n\nFailures observed after parse but before renderer steady state.\n\n| Reason                 | Cause                                                                                                                                |\n| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |\n| `UI_INITIALIZE_FAILED` | `ui/initialize` round-trip failed before bootstrap was readable, including no answer within 3 seconds on the declaration-level shell |\n| `WS_HANDSHAKE_FAILED`  | WebSocket rejected the bootstrap credential                                                                                          |\n| `UPGRADE_REQUIRED`     | Server-version not in client's supported set (also surfaces as `kind: 'version'` ProtocolError for finer handling)                   |\n\n### Transport-observable (pre-WebSocket)\n\nFailures the host can sometimes diagnose from outside the iframe.\n\n| Reason                | Cause                                                                                                       |\n| --------------------- | ----------------------------------------------------------------------------------------------------------- |\n| `BUNDLE_FETCH_FAILED` | `<script src={runtimeUrl}>` failed to load (for a content-hashed URL, after one retry of its unhashed twin) |\n| `CSP_VIOLATION`       | Host's Content-Security-Policy blocked something the renderer needs                                         |\n| `SESSION_NOT_FOUND`   | Server rejected pre-handshake — render expired or never existed                                             |\n| `AUTH_REJECTED`       | Server rejected pre-handshake — auth context invalid                                                        |\n| `(string & {})`       | First-party renderers MAY mint new reasons without a major bump.                                            |\n\nFor `CSP_VIOLATION` specifically, the host's own CSP is the usual root cause. Recommended UX: ask the user to check their browser console for the blocked directive.\n\n### Embedder CSP requirements\n\nA `srcdoc` iframe **inherits the embedding page's Content-Security-Policy**, and a GGUI render is not self-contained. A page with a strict `script-src` gets a blank view that reports only \"never negotiated with the host\" — the runtime bundle was blocked by `script-src-elem` before the network log ever saw it, so nothing inside the frame runs to explain itself. Every strict-CSP embedder hits this class; the allowances below are the whole ladder, parameterized by the deployment's MCP host (`<mcp-host>` is the origin your `ggui_render` results point at, e.g. `mcp.ggui.ai`; the runtime and its chunks are served from `assets.<mcp-host>` — the platform's CDN naming):\n\n| Directive                | Entry                                                                                                          | What it unblocks                                                                                                                                                                                                                              |\n| ------------------------ | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `script-src`             | `https://assets.<mcp-host>`                                                                                    | everything the frame executes: the runtime bundle (`/_ggui/iframe-runtime.<hash>.js`), the component module (`/code/<hash>.m<rt>.js`), its import shims (`/_ggui/shims/<rt>/…`), and the precompiled validator bundle (`/contract/<hash>.js`) |\n| `connect-src`            | `https://<mcp-host> wss://<mcp-host> https://assets.<mcp-host>`                                                | the render session's live channel (`/ws`, `/api/sessions/:id/events`, `/stream`) and code fetches                                                                                                                                             |\n| `style-src` / `font-src` | whatever your `hostContext.styles` imports (e.g. `https://fonts.googleapis.com` / `https://fonts.gstatic.com`) | only if you hand the view a font `@import` through host context                                                                                                                                                                               |\n\nThat's the whole recipe: **two host allowances** (plus the optional font row). No `blob:`, no `data:`, no `'unsafe-eval'` — everything the frame executes is a fetchable, immutable asset on the asset origin, including the outbound-action validators (precompiled server-side and shipped as functions, so client-side validation runs at full strength under the strictest policy). One exception remains on the legacy ladder: a render that imports an operator-registered **3rd-party gadget package** falls back to scheme-URL loading (its shims are derived per render and cannot be static assets) — such deployments keep `script-src blob: data:` until that path gets its own asset story.\n\nIf the view talks to a **hosted agent platform** as well (a chat kit relaying gestures through the platform's `tools/call` proxy), that platform's API origin belongs in `connect-src` too — the gesture never leaves the frame otherwise.\n\n**Diagnose from the embedder, not the frame.** The violation fires as a `securitypolicyviolation` event on the _embedding_ document — listen for it (filter `blockedURI` on `assets.<mcp-host>` / the session endpoints) and you can turn \"never negotiated with the host\" into \"this page's CSP blocks `<url>` — add `<directive> <entry>`\" for your users. The frame cannot report what it was never allowed to load.\n\n**Keep that channel clean on your side, too.** The frame under this recipe produces zero violations, so anything you see there is yours — and one common false alarm is worth naming: **zod v4** feature-probes codegen with `Function(\"\")` the first time an object schema is built and falls back correctly, but the attempt fires one `script-src eval` report from your page at boot (many chat kits and MCP client libraries build zod schemas at module init). Set `z.config({ jitless: true })` in a side-effect module that is your entry's _first_ import — a call placed later runs after the probe. GGUI's own reference embedder (the ggui.ai landing) and the runtime inside the frame both do exactly this.\n\n---\n\n## Bundle integrity (out of band)\n\nThe handshake itself carries no SRI hash for `runtimeUrl` — the runtime bundle's integrity is the server's responsibility (immutable cache-control + same-origin or trusted CDN). Integrity hashes DO appear on the bootstrap payload, but on adjacent fields, not on the handshake:\n\n- **`_meta[\"ai.ggui/render\"].codeHash`** — hex-encoded SHA-256 of the static-component bytes served at `codeUrl`. Paired with `codeUrl` (present together or absent together). Lets consumers verify content addressing without re-parsing the URL.\n- **`_meta[\"ai.ggui/render\"].gadgets[].bundleSri`** — SHA-384 SRI hash (`sha384-<base64>`) of each operator-registered gadget bundle. When present alongside `bundleUrl`, the iframe-runtime injects the gadget via `<script type=\"module\" integrity>` so the browser refuses execution on mismatch. Absent → integrity-less dynamic `import()` (back-compat for in-tree wrappers).\n\nA bootstrap MAY arrive without either field. The handshake's failure modes (`BUNDLE_FETCH_FAILED`, `CSP_VIOLATION`) do NOT include a hash-mismatch class — when SRI fires, the browser blocks the script and the runtime surfaces it as a downstream `BUNDLE_FETCH_FAILED`.\n\n---\n\n## Recovery posture\n\n| Failure                                        | Recoverable?                                                                                                                                                                                                                                                                                                                                                                                           |\n| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| Pre-render bootstrap-failed (`reason`-bearing) | Renderer will NOT auto-recover. Host MAY re-mount the iframe after fixing the root cause (refresh auth, clear CSP, etc.).                                                                                                                                                                                                                                                                              |\n| Post-render live-channel errors                | Surfaced as typed live-channel `error` frames (e.g. `CONTRACT_VIOLATION`); renderer continues running. See [Envelopes](/protocol/envelopes/).                                                                                                                                                                                                                                                          |\n| `UPGRADE_REQUIRED` (version mismatch)          | Terminal under the default `versionPolicy: 'reject'` — the server closes the connection and the failure surfaces as `ggui:bootstrap-failed` with reason `UPGRADE_REQUIRED`. Servers running the legacy `'advisory'` opt-out keep the connection open; the mismatch surfaces as a `schema-version-mismatch` `ggui:observe` event instead, and the host MAY render an inline \"update the client\" prompt. |\n\n---\n\n## Vanilla quickstart\n\nFor non-React hosts, the protocol is plain `<iframe>` + manual postMessage. The React wrapper is ~180 LOC of convenience; everything below is the wire contract.\n\n```html\n<!doctype html>\n<iframe id=\"ggui\" style=\"width:100%;height:100vh;border:0\"></iframe>\n<script type=\"module\">\n  const sessionId = \"…\"; // from ggui_render's structuredContent.sessionId\n\n  // 1. Fetch ResourceContents via MCP `resources/read` on the render's\n  //    `ui://ggui/render/<sessionId>` URI. Shown as a direct POST to a\n  //    local `ggui serve`; production hosts proxy this through their backend.\n  const rpc = await fetch(\"http://127.0.0.1:6781/mcp\", {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      Accept: \"application/json, text/event-stream\",\n      Authorization: \"Bearer dev\",\n    },\n    body: JSON.stringify({\n      jsonrpc: \"2.0\",\n      id: 1,\n      method: \"resources/read\",\n      params: { uri: `ui://ggui/render/${sessionId}` },\n    }),\n  }).then((r) => r.json());\n  const { contents } = rpc.result;\n  const resource = contents[0]; // { uri, mimeType: 'text/html', text }\n\n  // 2. Mount via srcdoc (inline HTML) OR src (http(s) URI).\n  const iframe = document.getElementById(\"ggui\");\n  if (resource.text) iframe.srcdoc = resource.text;\n  else iframe.src = resource.uri;\n\n  // 3. Subscribe to postMessage events from the iframe.\n  window.addEventListener(\"message\", (ev) => {\n    if (ev.source !== iframe.contentWindow) return;\n    const msg = ev.data;\n\n    if (msg?.type === \"ggui:renderer-ready\") {\n      console.log(\"renderer version\", msg.version);\n    } else if (msg?.type === \"ggui:bootstrap-failed\") {\n      // Terminal — render error UI naming msg.reason.\n      showError({ reason: msg.reason, message: msg.message });\n    } else if (msg?.type === \"ggui:observe\") {\n      record(msg.event);\n    } else if (msg?.type === \"ggui:lifecycle\") {\n      iframe.setAttribute(\"data-ggui-mcp-app-iframe-lifecycle\", msg.event.state);\n    } else if (msg?.jsonrpc === \"2.0\" && msg.method === \"ui/initialize\") {\n      // 4. Answer ui/initialize with host capabilities only — the\n      //    bootstrap slice does NOT ride this response.\n      iframe.contentWindow.postMessage(\n        {\n          jsonrpc: \"2.0\",\n          id: msg.id,\n          result: {\n            // McpUiInitializeResult: all four fields are required.\n            protocolVersion: msg.params.protocolVersion,\n            hostInfo: { name: \"my-host\", version: \"1.0.0\" },\n            hostCapabilities: { serverResources: {} },\n            hostContext: {\n              containerDimensions: { width: iframe.clientWidth, height: iframe.clientHeight },\n              availableDisplayModes: [\"inline\", \"fullscreen\"],\n            },\n          },\n        },\n        \"*\"\n      );\n      // 5. Then deliver the bootstrap slice on a\n      //    ui/notifications/tool-result notification.\n      iframe.contentWindow.postMessage(\n        {\n          jsonrpc: \"2.0\",\n          method: \"ui/notifications/tool-result\",\n          params: {\n            _meta: {\n              \"ai.ggui/render\": {\n                sessionId: \"…\",\n                appId: \"…\",\n                runtimeUrl: \"/_ggui/iframe-runtime.js\",\n                // mode discriminator: live (wsUrl+wsToken) | static-component (codeUrl) | system-card (kind)\n                wsUrl: \"wss://…\",\n                wsToken: \"…\",\n                // this mount's history epoch — absent reads as 0.\n                // The runtime freezes when a later props_update\n                // frame carries a HIGHER epoch (the freeze latch).\n                epoch: 0,\n              },\n            },\n          },\n        },\n        \"*\"\n      );\n    }\n    // tools/call, resources/read, ui/open-link, ui/notifications/size-changed,\n    // ui/message, ui/update-model-context, ui/request-display-mode — forward /\n    // handle as in the table above.\n  });\n</script>\n```\n\nThe `event.source` check is non-optional — without it, any window can spoof renderer envelopes. Production hosts SHOULD also validate `msg` against [the lifecycle envelope schema](https://github.com/ggui-ai/ggui/blob/main/packages/protocol/src/integrations/mcp-apps.ts) before mirroring state into trusted UI.\n\n---\n\n## Host obligations summary\n\nA host that implements the JSON-RPC methods above plus the four postMessage event handlers honors the protocol's bootstrap contract. The MUST / SHOULD breakdown:\n\n1. **MUST** honor `_meta[\"ai.ggui/render\"].runtimeUrl` from the resource — the iframe-runtime bundle URL.\n2. **MUST** classify `ggui:bootstrap-failed` onto `BootstrapFailureReason` (or render the raw string for unknown values).\n3. **MUST** surface `ggui:bootstrap-failed` as a user-visible error.\n4. **SHOULD** surface `ggui:observe` events to host telemetry.\n5. **MUST NOT** intercept `tools/call` JSON-RPC traffic — forward to the MCP server's tool registry verbatim.\n6. **MUST** narrow `event.source` to the iframe's `contentWindow` before reading any postMessage data.\n\nPart of this is graded by the [conformance kit](/protocol/conformance/)'s host-helper catalog (`host-helper-conformance`): a well-formed `ui/initialize`, truthful capability advertisement (a host that advertises `serverResources` must answer `resources/read`), honest and bounded refusals, and an unmodified `tools/call` relay. The DOM-level obligations above (`runtimeUrl`, bootstrap-failure surfacing, `ggui:observe`, the `event.source` check) are not graded by the kit.\n\n---\n\n## See also\n\n- [Protocol overview](/protocol/overview/) — three-channel topology; bootstrap is the path into the live channel.\n- [Envelopes](/protocol/envelopes/) — live-channel wire shapes (post-bootstrap traffic).\n- [Conformance](/protocol/conformance/) — the bar a host implementation must pass.\n- [`@ggui-ai/iframe-runtime` on GitHub](https://github.com/ggui-ai/ggui/tree/main/packages/iframe-runtime) — `ProtocolError` union, full `ObservabilityEvent` catalog, boot-sequence source.\n- [`useMcpAppsChat`](/host-helpers/react/) (from `@ggui-ai/mcp-apps-react/chat-helpers`) + `<AppRenderer>` (from `@mcp-ui/client`) — the web React surface that boxes everything above (React Native's host is `<McpAppIframe>`).\n- [`packages/console/src/routes/GguiSessions.tsx`](https://github.com/ggui-ai/ggui/blob/main/packages/console/src/routes/GguiSessions.tsx) — production reference implementation."},{"path":"/protocol/conformance.md","title":"Conformance","description":"What it means to be ggui-conformant — the 4-criterion contract bar, 6-criterion protocol bar, and the conformance kit as arbiter.","body":"---\ntitle: Conformance\ndescription: What it means to be ggui-conformant — the 4-criterion contract bar, 6-criterion protocol bar, and the conformance kit as arbiter.\n---\n\n> An implementation is ggui-conformant iff it passes the [conformance kit](https://github.com/ggui-ai/ggui/tree/main/packages/protocol-conformance) — a fixture-based test suite a candidate runs against. Opinion, intent, and prose-level reading of this site are NOT the arbiter; the kit is.\n\nThis page describes what the GGUI protocol promises in exchange for the name \"protocol\", what each contract inside it must satisfy, and how the kit makes those promises observable.\n\n## What conformance means\n\nConformance for GGUI means three concrete things:\n\n1. **A third-party implementation can replace the first-party one** — agents, hosts, and ops tools that work against the OSS `ggui serve` work against the third-party server too, without source-level changes.\n2. **Every named failure mode produces an observable signal** — operators see violations without running a debugger.\n3. **Every breaking change to the protocol is reproducible** — \"is this PR breaking?\" resolves to a kit run, not a debate.\n\nIf a layer that calls itself a \"protocol\" can't honor these three promises, the layer is something else (a shape, an SDK feature, an author convention) and should be named accordingly.\n\n## The Contract Bar — 4 criteria\n\nA **runtime contract** in GGUI MUST satisfy all four. Three of four is not a contract. Documenting a fourth in prose without mechanism is not a contract.\n\n### 1. Named parties\n\nThe contract states WHO is on each side. Not \"producer\" and \"consumer\" in the abstract — the concrete roles. The `ggui_runtime_submit_action` handler and the consume-buffer pipe. The iframe-runtime and the render-channel server. The contract author and the platform validator. If you can't name the parties in one sentence, there is no contract — only a data type adrift.\n\n- ✅ \"The `ggui_runtime_submit_action` handler appends every valid `kind: 'dispatch'` envelope to the render-keyed pending-events pipe; the agent drains it via `ggui_consume`.\"\n- ❌ \"Clients should respect the ordering semantics.\"\n\n### 2. Explicit obligations per party\n\nEach party has a list of MUSTs and MUST-NOTs. SHOULDs are discipline, not contract — a SHOULD belongs in a style guide. The obligations must be specific enough that a reviewer can literally check a diff against them.\n\n- ✅ \"Agent-authored `streamSpec` MUST NOT declare a channel whose name starts with `_ggui:`.\"\n- ❌ \"Handlers should behave predictably.\"\n\nAn obligation without a verifiable test is a hope, not a contract.\n\n### 3. Defined failure mode per obligation\n\nWhen the obligation breaks, there is a **named, typed, observable failure**. Generic `throw new Error()` is not a failure mode. `console.warn()` is not a failure mode. \"Log the outcome to telemetry and carry on\" is not a failure mode.\n\nAcceptable mechanisms:\n\n- A canonical error-code union (`CONTRACT_VIOLATION`, `SESSION_NOT_FOUND`, `SCHEMA_MISMATCH_ERROR`, the runtime-tool rejection codes `INVALID_ACTION_KIND` / `PIPE_NOT_FOUND` / `CONTEXT_TOO_LARGE`).\n- A typed rejection frame the caller receives on the call that caused it (e.g. a `CONTRACT_VIOLATION` error frame on the live channel, or a `{ok:false, code}` `structuredContent` rejection on a runtime tool).\n- Boot-time refusal with a message naming the offender (e.g. mount tool-name collision).\n- Compile-time impossibility (the type has no field → consumers can't read it).\n\nUnacceptable: silent noop, silent degradation, swallowed exception, \"it'll surface in the next call if it's a real problem.\"\n\n### 4. Observable violation\n\nAn operator can **see the violation without running a debugger**. Surfaces:\n\n- An error envelope/frame the caller observes synchronously (the gold standard — a `CONTRACT_VIOLATION` error frame on the live channel when an inbound action violates the contract).\n- A validation response the caller receives synchronously.\n- An operator UI that reads the above.\n- A structured log emitted at a known event name, documented as the contract's telemetry point.\n\nA contract whose violations only show up in production stack traces fails this bar — an operator who inherits the system in six months can't tell what's working and what's silently degraded.\n\n---\n\n## The Protocol Bar — 6 criteria\n\nA **protocol** is the emergent layer above a set of contracts. Every layer called \"a protocol\" MUST satisfy all six. Five of six is a protocol-in-progress; the gap MUST be flagged explicitly and the layer treated as experimental until closed.\n\n### 1. Wire-format specification\n\nA canonical prose spec describing every envelope shape, every field, every value constraint, and — critically — every intentional omission and why. Not just TypeScript types. Types say the shape; the spec says the **semantics**.\n\nFor GGUI, this lives in the site-level wire references — [Envelopes](/protocol/envelopes/), [MCP Protocol](/api/mcp-protocol/), [WebSocket Protocol](/api/websocket-protocol/) — which document every envelope shape, field, and intentional omission.\n\n### 2. Message sequencing and state\n\nGiven envelope X at time T in state S, which transitions are legal? What happens on reconnect? On resume? On concurrent emission? Crash recovery? Out-of-order delivery?\n\nA protocol without sequencing is a data type with pretensions. GGUI's sequencing lives in the [three-channel topology](/protocol/overview/) + the `GguiSessionStreamBuffer` interface + the `fromSeq` replay contract. Any new channel or envelope class MUST answer the sequencing question explicitly.\n\n### 3. Version negotiation\n\nProducer and consumer MUST be able to agree on what version they speak, or explicitly refuse. Stamping a version on every envelope is infrastructure; actually rejecting a mismatched peer is the protocol feature. Pre-launch \"advisory stamp only\" is acceptable IF the launch-cutover plan names the policy flip (producer-stamp + consumer-reject).\n\nAnti-pattern: calling version-stamping \"versioning\" and shipping. See [Version policy](/protocol/version-policy/) for the post-launch handshake semantics.\n\n### 4. Conformance testability at the seam\n\nA third-party implementer can verify their implementation without running the GGUI server. This means:\n\n- A public fixture table of envelopes and expected behaviors.\n- An executable conformance kit (a test package a third party `pnpm add`s and runs).\n- Coverage of every canonical failure mode — a third-party implementation that passes the kit can be used interchangeably with the first-party one.\n\nWithout this, \"third party adopts the protocol\" means \"third party reads the spec and hopes.\"\n\n### 5. Named failure modes — closed, or extensibly-closed\n\nEvery way the protocol can fail has a name, a code, and a shape. Acceptable forms:\n\n- **Closed union** — exhaustive by design. Bumping requires a major version bump. Use when the set of values is fixed by an external spec or by a pre-launch design decision the protocol owners control end-to-end.\n- **Extensibly-closed** — e.g. the `channel_error` code union `'CHANNEL_UNKNOWN' | 'SESSION_NOT_FOUND' | 'SUBSCRIBE_UNAUTHORIZED' | 'POLL_FAILED' | (string & {})`, or the open `code: string` on the WS `error` frame whose canonical literals include `CONTRACT_VIOLATION` and `UPGRADE_REQUIRED`. Consumers MUST handle unknown values gracefully. Adding new values does NOT bump the protocol version. Use when producer-side failure modes are expected to grow (new tool classes, new transports, new preconditions) without consumers needing a new version to render them.\n\nUnacceptable: ad-hoc `message: string` as the only failure surface.\n\n### 6. Vendor-neutral separation from implementation\n\nThe protocol package imports nothing from a specific runtime, transport, cloud vendor, or framework. `@ggui-ai/protocol` MUST remain consumable by a third-party implementation that has never seen `@ggui-ai/mcp-server`.\n\nViolation of this criterion is the loudest signal that what you have is an SDK feature pretending to be a protocol.\n\n---\n\n## The conformance kit\n\nThe kit lives at [`packages/protocol-conformance`](https://github.com/ggui-ai/ggui/tree/main/packages/protocol-conformance) in the open repo — a fixture-based test suite a candidate implementation runs against to demonstrate conformance.\n\n```bash\npnpm add -D @ggui-ai/protocol-conformance\n```\n\nBeyond the WS runner, the package ships a sub-entrypoint per catalog that is not a WS fixture. The [package README](https://github.com/ggui-ai/ggui/tree/main/packages/protocol-conformance#conformance-status) says what each is graded against. Among them:\n\n| Entrypoint                         | Kind                                   | Covers                                                                                             |\n| ---------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------- |\n| `schema-conformance`               | Pure function                          | Gadget schema obligations.                                                                         |\n| `registration-conformance`         | Pure function                          | Gadget registration obligations.                                                                   |\n| `resolution-conformance`           | Pure function                          | Gadget resolution obligations.                                                                     |\n| `binding-conformance`              | Pure function                          | MCP tool-binding resolution and search-filter semantics.                                           |\n| `resource-read-conformance`        | Driver (live MCP server)               | A `resources/read` catalog — the kit's first MCP-binding driver.                                   |\n| `domain-error-conformance`         | Driver (`tools/call`)                  | Domain errors as tool results whose text leads with the registered slug (§7.9 Plane 2).            |\n| `dispatch-idempotency-conformance` | Driver (`tools/call`) + a session seam | A retried `ggui_runtime_submit_action` dispatch is one gesture (§11.1); SHOULD level this release. |\n\nImport them as `@ggui-ai/protocol-conformance/<entrypoint>`. The raw fixture catalog is also exported at `@ggui-ai/protocol-conformance/fixtures`.\n\nProgrammatic and CLI entry points:\n\n```ts\nimport { runConformance } from \"@ggui-ai/protocol-conformance\";\n\nconst result = await runConformance({\n  serverUrl: \"http://localhost:3000\",\n  auth: { kind: \"bearer\", token: process.env.TOKEN! },\n});\n\nif (result.failed.length > 0) process.exit(1);\n```\n\n```bash\nnpx ggui-protocol-conformance --url http://localhost:3000 --auth bearer:$TOKEN\n```\n\nA fixture grades a MUST unless it declares `\"level\": \"should\"`. An unmet SHOULD is a **warning**: it lands in `result.warned` with the same evidence a failure carries and never fails a run, so the exit code above ignores it. The first SHOULD case is `fresh-subscribe-replays-reserved-preview` (SPEC §12.2.1): a fresh subscribe replays a known-reserved channel's retained envelopes after the ack.\n\n`serverUrl` / `--url` take the implementation's **base** `http://` / `https://` URL — the runner appends `/ws` and derives the `ws://` / `wss://` scheme itself.\n\n**Transport.** The behavioral fixture catalog is **WebSocket-only** — the canonical GGUI transport (see the [WebSocket Protocol reference](/api/websocket-protocol/)). `resource-read-conformance` adds a second binding: an MCP `resources/read` catalog driven against a live MCP server. The kit's `TransportConfig` is an extensibly-closed union, so further bindings (stdio MCP, HTTP long-poll) can be added without breaking the public API.\n\n**Path-A vs Path-B fixtures.** The fixture catalog spans both wire-observable claims and surface-observable claims. The runner handles **Path-A** — behaviors a runner can assert from WS frames alone (no MCP-Apps-host adapter, no Playwright). **Path-B** fixtures (e.g., `bootstrap-failure`, `props-update`) require a browser-host harness driving Playwright + `page.route()` fault injection + DOM assertion; they are recorded as SKIP on the WS runner, not FAIL. The partition is intentional: Path-A FAILs are vendor-neutrality bugs the server owns; Path-B SKIPs are claims a different driver is responsible for.\n\nThe kit covers:\n\n- **Envelope round-trip** — every fixture in the table can be emitted, observed, validated, and round-tripped.\n- **Reserved-channel authority** — the implementation rejects agent-authored `streamSpec` entries in the `_ggui:` namespace and validates the platform-owned shapes (`_ggui:preview`, `_ggui:lifecycle`).\n- **Schema enforcement** — `actionSpec[action].schema` ⊆ the hinted tool's `inputSchema`, with a `SCHEMA_MISMATCH_ERROR` push-time rejection on violation.\n- **Sequencing** — `seq` is gap-free per-session, `fromSeq` replay returns the right tail, `replayTruncated` is honored when the requested cursor is unrecoverable.\n- **Version handshake** — schema-version stamping on every envelope; `UPGRADE_REQUIRED` emission on mismatch (post-launch — pre-launch is advisory).\n- **Action persistence** — an action frame round-trips to an ack carrying `payload.sequence`, proving the event landed on the GguiSession's consume buffer (append-then-ack).\n- **Contract enforcement at receipt** — an action for an undeclared name is rejected with a `CONTRACT_VIOLATION` error frame and nothing reaches the consume buffer.\n\nA change is **breaking** iff at least one fixture that passed against version N now fails against version N+1 when the only change is the protocol version. See [Version policy → §2](/protocol/version-policy/#2-breaking-change-definition--the-kit-is-the-arbiter) for the policy this anchors.\n\n---\n\n## Pre-launch vs launch posture\n\nPre-launch (`draft-` versions), some protocol criteria are intentionally at ⚠️ — flagged gaps with owners and closing slices. The point is not zero gaps forever; it is **zero silent gaps**.\n\n| Criterion             | Pre-launch posture                                                                                                                                                                                                                                                                    | Launch (v1.0)                                                                                                                                       |\n| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Version negotiation   | Envelope `schemaVersion`: advisory stamp; consumers MUST NOT reject on mismatch. Subscribe handshake (`supportedVersions` / `serverVersion`): server default `versionPolicy: 'reject'` — emits `UPGRADE_REQUIRED` and closes; `'advisory'` is a legacy opt-out for migration windows. | Same mechanisms; envelope stamps stay advisory, the subscribe handshake stays the rejection point. Mismatched majors surface as `UPGRADE_REQUIRED`. |\n| Conformance kit       | Optional today; available for first-party servers + early third-party implementers.                                                                                                                                                                                                   | Required before third-party implementers are invited to build on the protocol.                                                                      |\n| Schema-compat checker | Render-time + console blueprint-try (`SCHEMA_MISMATCH_ERROR` rejection on the `ggui_render` tool result); operator policy `'reject' \\| 'warn' \\| 'off'` (default `'reject'`).                                                                                                         | Same; default unchanged.                                                                                                                            |\n\nAt launch, all load-bearing boundaries MUST be at 4/4 contract + 6/6 protocol for any layer described as a public interop surface. Load-bearing = any boundary a third-party implementer is expected to adopt. Internal-only boundaries may sit at lower scores if the gap is explicitly scoped to internal use.\n\n---\n\n## Anti-patterns\n\nThings that feel contract-shaped but fail the bar:\n\n| Looks like                            | Is actually                  | Why                                                        |\n| ------------------------------------- | ---------------------------- | ---------------------------------------------------------- |\n| \"TypeScript type is the contract\"     | Shape-only                   | Types say what, not who/when/what-on-violation             |\n| \"Docstring says MUST\"                 | Author discipline            | Prose without mechanism decays                             |\n| \"A validator function exists\"         | Half-contract                | Validator without observable failure path wastes the check |\n| \"Error gets logged\"                   | Not observable               | Logs aren't observability unless somebody reads them       |\n| \"Pre-launch advisory\"                 | Infrastructure, not contract | Valid interim stance; MUST flag the gap + name the flip    |\n| \"We cover the common cases\"           | Denylist discipline          | Rock-paper-scissors with future adversaries                |\n| \"Three similar implementations agree\" | Folklore                     | Interop-by-intuition is not interop                        |\n\nIf a new \"contract\" maps to a row here, either strengthen it to meet the bar or pick a different word.\n\n---\n\n## Vocabulary\n\nUsed precisely throughout this site:\n\n- **Shape** — a TypeScript type with no enforcement story.\n- **Convention** — author discipline; no mechanism.\n- **Contract** — passes the 4-criterion bar.\n- **Protocol** — passes the 6-criterion bar; a set of contracts with sequencing + versioning + conformance-kit.\n\nIf a doc says \"this contract is enforced by convention\" — that's a category error and either the contract framing or the convention framing is wrong.\n\n---\n\n## See also\n\n- [Protocol overview](/protocol/overview/) — three-channel topology and reference implementation.\n- [Version policy](/protocol/version-policy/) — semver semantics, breaking-change definition, deprecation timeline.\n- [Envelopes](/protocol/envelopes/) — wire shapes the conformance kit asserts on.\n- [Conformance kit on GitHub](https://github.com/ggui-ai/ggui/tree/main/packages/protocol-conformance) — the test package itself."},{"path":"/protocol/envelopes.md","title":"Live-channel envelopes","description":"Wire shapes for ActionEnvelope (inbound), StreamEnvelope (outbound), and the reserved _ggui namespace.","body":"---\ntitle: Live-channel envelopes\ndescription: Wire shapes for ActionEnvelope (inbound), StreamEnvelope (outbound), and the reserved _ggui namespace.\n---\n\nThe live WebSocket carries three body shapes:\n\n| Shape                | Direction       | Carries                                                        |\n| -------------------- | --------------- | -------------------------------------------------------------- |\n| `ActionEnvelope`     | client → server | A canonical user action (form submit, click, …)                |\n| `StreamEnvelope`     | server → client | An outbound delivery on a named stream channel                 |\n| `PropsUpdatePayload` | server → client | The full replacement props state for an already-mounted render |\n\nThe first two are the envelopes an agent-declared contract validates against. `PropsUpdatePayload` carries no contract-declared channel — it is the state fan-out — but it rides the same socket, so it is documented here too. For the complete frame catalog (`subscribe`, `ack`, `render`, `render_event`, `channel_*`, …) see the [WebSocket Protocol reference](/api/websocket-protocol/#frame-catalog).\n\nCanonical wire reference. Shapes below mirror the TypeScript in [`@ggui-ai/protocol`](https://github.com/ggui-ai/ggui/tree/main/packages/protocol/src) verbatim: `types/events.ts` (ActionEnvelope), `types/live-channel.ts` (StreamEnvelope, PropsUpdatePayload).\n\n:::tip[Protocol version]\nWire shapes documented here are stable at `draft-2026-09-10`. See [Version policy](/protocol/version-policy/) for breaking-change semantics.\n:::\n\n---\n\n## ActionEnvelope\n\nInbound live-channel envelope — the body of a `type: 'action'` WebSocket message. Flat, narrow, limited to fields the server actually enforces or diagnostic fields real consumers populate today.\n\n```ts\ninterface ActionEnvelope<TPayload = JsonValue> {\n  sessionId: string; // required — render identity\n  type: EventType; // required — see EventType below\n  payload?: TPayload; // shape depends on `type`\n  clientSeq?: number; // client-monotonic dedup hint\n  schemaVersion?: string; // producer's PROTOCOL_SCHEMA_VERSION (advisory pre-launch)\n}\n```\n\n| Field           | Type        | Required | Semantics                                                                                                                                                                                                                                                                                                                 |\n| --------------- | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `sessionId`     | `string`    | Yes      | Server enforces subscriber-render binding — envelopes whose `sessionId` doesn't match the render the WebSocket subscriber is bound to are rejected with `SESSION_MISMATCH`.                                                                                                                                               |\n| `type`          | `EventType` | Yes      | Always `data:submit` — the only member of the `EventType` union (see below). The active render's `actionSpec` gates which action names are accepted. (The pre-actionSpec `EventSubscription` / `DEFAULT_SUBSCRIPTION` gating shapes were deleted from `@ggui-ai/protocol` — there is no subscription gating on the wire.) |\n| `payload`       | `TPayload?` | No       | For `type: 'data:submit'` this carries an `ActionEventValue` shape (`{action, data, tool?}`) whose `data` field is validated against the render's `actionSpec[action].schema`.                                                                                                                                            |\n| `clientSeq`     | `number?`   | No       | Client-monotonic sequence number for at-least-once dedup. Declared shape; no server enforcement today (no inbound dedup infrastructure yet). Clients SHOULD populate when their transport can replay (e.g., reconnect-with-backfill).                                                                                     |\n| `schemaVersion` | `string?`   | No       | Protocol schema version stamped by the producer. Pre-launch: advisory — consumers MUST NOT reject on mismatch. Post-launch: receiving major mismatches surface as `UPGRADE_REQUIRED`.                                                                                                                                     |\n\n### Fields intentionally NOT on the envelope\n\nThese fields appear on neighboring shapes but are excluded from `ActionEnvelope` by design:\n\n| Field                             | Why omitted                                                                                             |\n| --------------------------------- | ------------------------------------------------------------------------------------------------------- |\n| `appId`                           | Server resolves it from the render; client-claimed values are ignored for enforcement.                  |\n| `userId` / `user`                 | Diagnostic render metadata captured at subscribe time, not per-delivery.                                |\n| `deviceInfo` / `interfaceContext` | Same as above — render-level, not action-level.                                                         |\n| `componentId` / `contractHash`    | Diagnostic; no enforcement consumer today.                                                              |\n| `timestamp`                       | Server uses its own clock for ordering + log emission; client-supplied timestamps aren't authoritative. |\n| `correlationId`                   | The doctrine names this for agent-push ↔ user-action pairing; `sessionId` covers the narrow case today. |\n\n### `EventType`\n\n`EventType` is a single-member union — `'data:submit'` is the only action type the protocol recognizes:\n\n| Type          | Notes                                                                   |\n| ------------- | ----------------------------------------------------------------------- |\n| `data:submit` | Carries `ActionEventValue` (`{action, data, tool?}`); schema-validated. |\n\nThe old `data:change` / `lifecycle:focus` / `lifecycle:blur` / `interaction:click` / `interaction:hover` / `interaction:scroll` / `error:validation` / `error:connection` members were removed in `draft-2026-06-12` — they had no producers.\n\n### `ActionEventValue`\n\nPayload shape for `data:submit`:\n\n```ts\ninterface ActionEventValue<TData = unknown> {\n  action: string; // action ID from the contract (e.g. \"submit\", \"archive\")\n  data: TData; // action payload (e.g. form data)\n  tool?: string; // MCP tool name mirrored from actionSpec[action].nextStep (when declared)\n}\n```\n\n**Derivation.** `tool` is derived server-side from `actionSpec[action].nextStep` at event-build time — clients do not populate it. When the action has no declared `nextStep`, `tool` is absent — the agent decides the next tool freely from broader context. The hint is advisory either way; the agent owns the call decision.\n\n---\n\n## StreamEnvelope\n\nOutbound live-channel envelope — the body of a `type: 'data'` WebSocket message. Carries a single delivery on a named stream channel.\n\n```ts\ninterface StreamEnvelope {\n  sessionId: string; // required\n  channel: string; // required — keys into streamSpec\n  mode: StreamChannelMode; // required — 'append' | 'replace'\n  payload: JsonValue; // required — channel-specific shape\n  complete?: boolean; // terminal marker for completable channels\n  seq?: number; // server-assigned monotonic outbound sequence\n  schemaVersion?: string; // producer's PROTOCOL_SCHEMA_VERSION\n}\n```\n\n| Field           | Type                | Required | Semantics                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |\n| --------------- | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `sessionId`     | `string`            | Yes      | Render this delivery belongs to.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |\n| `channel`       | `string`            | Yes      | Channel name. Keys into the agent's declared `streamSpec`. Names starting with `_ggui:` are server-owned (see [Reserved channels](#reserved-channels)).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |\n| `mode`          | `StreamChannelMode` | Yes      | State-folding mode. `'append'` (default for narrative streams — message log, telemetry) accumulates deliveries; `'replace'` (default for snapshot streams — task progress, session state) replaces the current value. Senders declare; receivers honor. Typically equals the channel's declared `mode` on the spec, but the envelope is the authoritative per-delivery signal.                                                                                                                                                                                                                                                                                                                                                                         |\n| `payload`       | `JsonValue`         | Yes      | Validated against `streamSpec[channel].schema`. Shape is channel-specific; consumers typecheck via contract inference when they use `defineContract` + `useStream`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |\n| `complete`      | `boolean?`          | No       | Terminal completion marker — truthy on the last delivery for a completable channel (one declared with `complete: true` on the spec). Consumers use this to transition subscribers into a \"channel closed\" state. Absent on non-terminal deliveries.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |\n| `seq`           | `number?`           | No       | Render-scoped monotonic outbound sequence. Server-assigned; clients MUST NOT populate it on producer-side inputs. Gap-free within a single render, starting at 1. Used by the client to track `lastSeenSeq`, the highest `seq` it has applied, for reconnect (pass it back as `SubscribePayload.fromSeq`), and to dedupe deliveries against it (at-least-once semantics), never against the ack's `streamSeq` (SPEC §12.2.1). OPTIONAL because stamping is best-effort: both OSS `@ggui-ai/mcp-server` and hosted cloud stamp `seq` on every successfully buffered delivery, but a delivery whose buffer record failed ships unstamped rather than blocking the emit. A future slice promotes this to required once buffering is guaranteed non-lossy. |\n| `schemaVersion` | `string?`           | No       | Same advisory-pre-launch / `UPGRADE_REQUIRED`-post-launch semantics as on `ActionEnvelope`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |\n\n### Fields intentionally NOT on the envelope\n\n| Field       | Why omitted                                                                                                                                       |\n| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `replay`    | Per-channel policy declared on `streamSpec[channel].replay`. A per-delivery field would imply replay can vary message-to-message, which it can't. |\n| `timestamp` | Replay correctness needs `seq` only; timestamp is a future optional addition driven by a concrete client-UX need.                                 |\n\n---\n\n## PropsUpdatePayload\n\nServer → client. The body of a `type: 'props_update'` message — the `ggui_update` / `ggui_amend` fan-out. Replaces props on an already-rendered component without re-generation.\n\n```ts\ninterface PropsUpdatePayload {\n  sessionId: string;\n  props: JsonObject;\n  epoch?: number;\n}\n```\n\n| Field       | Type         | Required | Description                                                                                                                                                   |\n| ----------- | ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `sessionId` | `string`     | Yes      | The render being updated.                                                                                                                                     |\n| `props`     | `JsonObject` | Yes      | **Full replacement state**, not a patch — post-merge for `kind: \"merge\"` updates. Persistence is the source of truth; this frame is the latency optimization. |\n| `epoch`     | `number?`    | No       | The commit-time history epoch. `ggui_update` stamps its just-advanced epoch; `ggui_amend` stamps the unchanged head.                                          |\n\n`epoch` is the **freeze-latch signal**. A mount whose own epoch is lower than the frame's has been superseded and MUST freeze rather than apply — that is what keeps an old history card from silently repainting with state belonging to a newer one.\n\nAbsent `epoch` reads as `0`: pre-epoch producers and amends against an epoch-0 head omit it. Consumers MUST tolerate the absence rather than reject the frame.\n\n---\n\n## Reserved channels\n\nChannel names starting with `_ggui:` are **reserved** for the server. Agent-authored `streamSpec` MUST NOT declare any name in this namespace; structural validation rejects every reserved-prefix entry with the message `Stream channel '<name>' is in the reserved '_ggui:' namespace — server-owned channels cannot be declared in agent streamSpec`.\n\nTwo channel names are recognized today:\n\n| Channel           | Owner         | Payload shape                                      | Purpose                                                                                                                                                                                 |\n| ----------------- | ------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `_ggui:preview`   | Server (A2UI) | A2UI `ServerMessage` — see `@ggui-ai/preview-a2ui` | Provisional A2UI assembly stream emitted by the server during fresh-gen `ggui_render` flows. The renderer subscribes implicitly and dispatches the payload through its preview surface. |\n| `_ggui:lifecycle` | Server        | `GguiLifecyclePayload`                             | Generation-progress lifecycle kinds (`handshake_started`, `handshake_completed`, `render_started`, `consume_polling`) the renderer surfaces as a progress affordance.                   |\n\nA typo inside the reserved namespace (e.g. `_ggui:preveiw`) is NOT recognized — delivery falls through to the normal \"unknown channel\" rejection rather than silently passing as a reserved channel.\n\nThe `_ggui:contract-error` channel was a reserved channel in earlier drafts but was removed entirely in `draft-2026-06-11` — the channel, its payload shape, and its validator are gone. Contract violations now surface as a typed `CONTRACT_VIOLATION` error frame on the live channel on the call that caused them; nothing lands on the consume buffer.\n\n---\n\n## Schema versioning\n\nThe `schemaVersion` field is present on both envelopes. It carries the producer's `PROTOCOL_SCHEMA_VERSION` constant.\n\n**Pre-launch (`draft-` versions):** advisory. Consumers MUST NOT reject on mismatch; missing `schemaVersion` fields are normal.\n\n**Post-launch:** receiving an envelope whose major version diverges from the consumer's known major surfaces as a live-channel error with `code: UPGRADE_REQUIRED`. The handshake resolution flow is documented in the [WebSocket Protocol](/api/websocket-protocol/) reference.\n\nSee [Version policy](/protocol/version-policy/) for the full mapping of what counts as a major / minor / patch bump.\n\n---\n\n## See also\n\n- [WebSocket Protocol](/api/websocket-protocol/) — live-channel message types (`subscribe`, `action`, `ack`, `render`, `data`, `error`) that carry these envelope payloads.\n- [MCP Protocol](/api/mcp-protocol/) — MCP JSON-RPC methods (`ggui_handshake`, `ggui_render`, `ggui_consume`, etc.).\n- [Protocol overview](/protocol/overview/) — the three-channel topology.\n- [Version policy](/protocol/version-policy/) — semver semantics, breaking-change definition, deprecation timeline.\n- [`@ggui-ai/protocol` source](https://github.com/ggui-ai/ggui/tree/main/packages/protocol/src) — canonical TypeScript definitions."},{"path":"/protocol/overview.md","title":"Protocol overview","description":"GGUI is an open protocol for AI agents to render interactive UIs over three wire channels. MCP-native, framework-agnostic, server-enforced.","body":"---\ntitle: Protocol overview\ndescription: GGUI is an open protocol for AI agents to render interactive UIs over three wire channels. MCP-native, framework-agnostic, server-enforced.\n---\n\n`ggui` is an **open protocol** that lets AI agents render interactive UIs to humans. An agent describes what it needs in natural language; GGUI generates React components, delivers them over a typed live channel, and routes user actions back through a server-side enforcement point.\n\nThis site documents the protocol itself, version `draft-2026-09-10` (see `PROTOCOL_SCHEMA_VERSION` in [`@ggui-ai/protocol`](https://github.com/ggui-ai/ggui/tree/main/packages/protocol/src/version.ts)). Everything below is implementable from the spec — the open-source [`ggui serve`](/cli/serve/) is the reference conformant implementation, and hosted GGUI at `https://mcp.ggui.ai` runs the same protocol. The `@ggui-ai/*` packages are the reference implementations.\n\n:::note[GGUI vs guuey]\n**GGUI** (this site) is the open protocol — published at [github.com/ggui-ai/ggui](https://github.com/ggui-ai/ggui); a hosted endpoint at `mcp.ggui.ai` runs it too. **guuey** is a separate commercial agent-hosting platform at [guuey.com](https://guuey.com) that speaks the GGUI protocol. Don't conflate them.\n:::\n\n## Three parties, three channels\n\n```\n                 ┌───────────────────────┐\n                 │      user client      │\n                 │  (React / RN / web)   │\n                 └─────┬────────────┬────┘\n                       │            │\n                  chat        live (WebSocket)\n                (HTTP SSE)         ↑↓\n                       │            │\n              ┌────────▼────┐   ┌───▼────────────┐\n              │  wrapped-   │   │                │\n              │   agent     │   │   core-mcp     │\n              │ (developer  │───▶ (session       │\n              │   code)     │ MCP  authority)    │\n              └─────────────┘   └────────────────┘\n                          MCP\n                       (over HTTP)\n```\n\n| Channel | Direction              | Transport     | Purpose                                           | Status    |\n| ------- | ---------------------- | ------------- | ------------------------------------------------- | --------- |\n| Chat    | user ↔ wrapped-agent   | HTTP SSE      | Conversational surface (chat-completion style)    | Optional  |\n| MCP     | agent ↔ core-mcp       | MCP over HTTP | Tool control + session mutation                   | Mandatory |\n| Live    | core-mcp ↔ user client | WebSocket     | Live UI session delivery + canonical user actions | Mandatory |\n\nThe MCP and live channels are the **protocol kernel** — every conformant implementation MUST provide them. The chat channel is product-mandatory (the OSS `ggui serve` ships a chat surface) but protocol-optional: an embed that uses GGUI purely for typed live UIs, with no conversational chat, is still conformant.\n\n:::note[Channel names, not numbers]\nThis page describes the conversational stack — the three transports between a user, a wrapped agent, and the GGUI server: **chat / MCP / live**. The [Architecture overview](/architecture/overview/) describes the wire pipeline instead — **bootstrap / MCP / live**. The two views share MCP and the live channel; they differ only on the third: the protocol view names the chat surface the agent and user talk over, while the architecture view names the bootstrap fetch that delivers the compiled component to the renderer. Naming channels by their transport keeps the two views consistent.\n:::\n\nThe WebSocket endpoint is `ws://127.0.0.1:6781/ws` (default) when running `ggui serve` locally; self-hosters expose it as `wss://<your-server>/ws`. (The hosted endpoint is `wss://mcp.ggui.ai/ws`.)\n\n## Why the live channel is mandatory\n\nThe typed live-channel contract needs a server-side enforcement point. That point is the live channel itself.\n\nA `streamSpec` declares named streams, payload schemas (validated as `StreamEnvelope`s), replay policy, ordering, and completion semantics. Subscriptions declare which events the client UI consumes. None of that means anything unless a non-agent authority validates it on the wire. If the agent shipped bytes directly to the user, the \"contract\" would be documentation, not contract — see [Conformance](/protocol/conformance/) for the 4-criterion contract bar this enforces.\n\nSo **no live channel, no enforcement point, no contract.** The live channel and the typed live-channel contract are a pair — reconsider one, you reconsider the other.\n\n## Wire surfaces\n\nEach channel maps to a documented wire surface on this site:\n\n| Surface                                             | Channel    | What it covers                                                                                                                                                                                                                                                                                                                                                                   |\n| --------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| [MCP Protocol](/api/mcp-protocol/)                  | MCP        | JSON-RPC methods (canonical lifecycle order): `ggui_handshake`, `ggui_render`, `ggui_consume`, `ggui_update`, `ggui_amend`, `ggui_emit`, `ggui_get_session`, `ggui_get_render_source`, `ggui_list_sessions`, plus gadget/theme/blueprint discovery (`ggui_list_gadgets`, `ggui_list_themes`, `ggui_list_featured_blueprints`, `ggui_search_blueprints`, `ggui_render_blueprint`) |\n| [WebSocket Protocol](/api/websocket-protocol/)      | Live       | Subscribe / `ActionEnvelope` / ack / render / `StreamEnvelope`; replay; reconnection                                                                                                                                                                                                                                                                                             |\n| [MCP Apps support](/api/mcp-apps/)                  | MCP + Live | Resource shape (`{contents:[{uri,mimeType,text}]}`); host-side iframe-runtime boot                                                                                                                                                                                                                                                                                               |\n| [OAuth (`ggui serve --oauth`)](/self-hosted/oauth/) | MCP        | OAuth 2.1 + PKCE + Dynamic Client Registration for hosts with no pre-shared bearer                                                                                                                                                                                                                                                                                               |\n\n## Reference implementation\n\nThe open implementation lives at [`github.com/ggui-ai/ggui`](https://github.com/ggui-ai/ggui):\n\n- `packages/mcp-server` — the MCP + live-channel server.\n- `packages/mcp-apps-react` / `packages/mcp-apps-react-native` — the web and React Native MCP-Apps host helpers. They wrap the host side of the handshake; rendering semantics live in the mounted view, not in these packages.\n- `packages/ggui-cli` — ships [`ggui serve`](/cli/serve/), which runs the whole protocol locally on one command.\n\nThe self-hosted open-source path runs the whole protocol on one command via `ggui serve`. Hosted GGUI at `https://mcp.ggui.ai` runs the same protocol on managed infrastructure, with no self-host required.\n\n## Core vocabulary\n\nThree nouns recur on every page. Get them right and the rest of the spec falls into place — see the [glossary](/glossary/) for the full list.\n\n- **Gadget** — a renderer-side capability (Mapbox, Leaflet, a charting library). Declared per-app in `ggui.json#app.gadgets` (the `@ggui-ai/gadgets` stdlib is always the floor); loaded at iframe boot. The agent never touches gadget code.\n- **Tool** — an agent-side action exposed over MCP (`ggui_render`, `ggui_update`, `ggui_amend`, etc.). Tools mutate session state.\n- **Blueprint** — a cached recipe (component code + contract) keyed by intent. Matched on the reuse path; generated on miss.\n\nGadgets and tools are the protocol's two **symmetric capability surfaces** — gadgets give the renderer something to render with; tools give the agent something to act with. Both are operator-bounded and declared per-app (`clientCapabilities.gadgets`, `agentCapabilities.tools`). See [glossary → Capabilities](/glossary/#capabilities).\n\n## What the protocol is not\n\n- **Not a renderer.** The protocol carries component code and props; how the host evaluates that code is its concern. The reference implementation uses dynamic ESM import plus the `@ggui-ai/iframe-runtime` bundle (the self-contained ESM bundle that boots inside the MCP-Apps iframe), but a conformant host can swap in a sandboxed iframe, a server-rendered HTML pipeline, or anything else.\n- **Not a UI library.** Components are generated per render by the agent; GGUI ships no fixed component set. Primitives (Button, Input) are catalog-declared, not protocol-declared.\n- **Not framework-coupled.** The wire is JSON over HTTP and WebSocket. React has the most-developed host helpers today, but `@ggui-ai/protocol` types and `@modelcontextprotocol/sdk` (Anthropic's official MCP TypeScript SDK) are framework-neutral.\n\n## Versioning + conformance\n\nThe protocol follows semver. The arbiter for \"what counts as a breaking change\" is the **conformance kit** at [`packages/protocol-conformance`](https://github.com/ggui-ai/ggui/tree/main/packages/protocol-conformance) — a fixture-based test suite that any candidate implementation runs against.\n\nA change is **major** iff at least one conformance fixture that passed against version N now fails against version N+1, with the protocol version as the only delta. See the [Version policy](/protocol/version-policy/) for the full semver mapping, deprecation timeline, and version support matrix.\n\n## See also\n\n- [MCP Protocol reference](/api/mcp-protocol/) — MCP wire grammar.\n- [WebSocket Protocol reference](/api/websocket-protocol/) — live-channel wire grammar.\n- [Envelopes](/protocol/envelopes/) — the live-channel wire shapes: `ActionEnvelope` (inbound) and `StreamEnvelope` (outbound), plus the reserved `_ggui:` channel namespace.\n- [Bootstrap handshake](/protocol/bootstrap-handshake/) — host-side postMessage + JSON-RPC contract for mounting a session in an iframe.\n- [Conformance](/protocol/conformance/) — the kit, the bar, and how to claim conformance.\n- [`ggui serve`](/cli/serve/) — run the protocol locally."},{"path":"/protocol/showcase-trimly.md","title":"Showcase: a real turn","description":"One real conversation with a live ggui-rendering agent, narrated beat by beat — every \"On the wire\" panel is a frame recorded off the wire, not prose about what would happen.","body":"---\ntitle: Showcase: a real turn\ndescription: One real conversation with a live ggui-rendering agent, narrated beat by beat — every \"On the wire\" panel is a frame recorded off the wire, not prose about what would happen.\n---\n\n{/* Every claim this page makes about how a turn WENT — the counts AND the\n    which-call-shape flags — is read off the same frames the panels show,\n    never asserted in prose and never defaulted. The manifest declares the\n    same four facts; they are reconciled here rather than trusted, so a\n    manifest that drifts from the recording reds the build instead of putting\n    a false sentence above a panel that contradicts it. */}\nexport const renderStats = (turn, n) => {\n  const kinds = frameKinds(turn.frames);\n  const results = turn.frames.filter((_, i) => kinds[i] === \"message:tool.done:ggui_render\");\n  const refusals = results.filter((f) => f.data.isError === true);\n  const succeeded = results.filter((f) => f.data.isError !== true);\n  const counted = {\n    renderAttempts: results.length,\n    refusedRenders: refusals.length,\n    hasRender: succeeded.length > 0,\n    // Mirrors hasRender, and mirrors the capture rig's turnFlags(): a REFUSED\n    // update is not an update. Counting any tool.done would make a recording\n    // with a rejected ggui_update disagree with its own manifest.\n    hasUpdate: turn.frames.some(\n      (f, i) => kinds[i] === \"message:tool.done:ggui_update\" && f.data.isError !== true\n    ),\n  };\n  // Under the read-plane-only posture (guuey#209 C3, armed on prod\n  // 2026-08-16) the tool result carries NO _meta: identity only, on\n  // structuredContent + the text block; the mount material lives behind\n  // resourceUri via resources/read. A recording that DOES carry the\n  // slice (a host or deployment that emits it) is narrated as such.\n  const bootstrap = succeeded[0]?.data._meta?.[\"ai.ggui/render\"];\n  const carriesSlice = bootstrap !== undefined;\n  const structured = succeeded[0]?.data.structuredContent;\n  const okText = String(succeeded[0]?.data.content?.[0]?.text ?? \"\");\n  // A successful render's first content block is the result JSON, but a host\n  // that framed it differently must not red the build with a bare SyntaxError.\n  let okResult = null;\n  try {\n    okResult = JSON.parse(okText);\n  } catch {\n    okResult = null;\n  }\n  // Honesty gate (read-plane-only posture): a successful render result MUST carry\n  // its identity on structuredContent — under _meta withhold that is the ONLY\n  // machine-readable place resourceUri lives, and beat 3's prose says so. A\n  // recording where it is missing would narrate a contract the panel refutes.\n  if (succeeded.length > 0 && !(structured !== null && typeof structured === \"object\" && typeof structured.resourceUri === \"string\")) {\n    throw new Error(\n      `showcase-trimly: turn ${n}'s successful render carries no structuredContent.resourceUri — the read-plane-only ` +\n        `posture requires the identity there (beat 3 narrates it); check the recording's host framing`\n    );\n  }\n  const carriesOverride = turn.frames.some(\n    (f, i) =>\n      kinds[i] === \"message:tool.args.assembled:ggui_render\" && f.data.input?.override !== undefined\n  );\n  const declared = manifest.turns.find((t) => t.n === n);\n  if (!declared) {\n    throw new Error(`showcase-trimly: the manifest carries no entry for turn ${n}`);\n  }\n  if (declared.renderAttempts === undefined || declared.refusedRenders === undefined) {\n    throw new Error(\n      `showcase-trimly: manifest turn ${n} carries no renderAttempts/refusedRenders — it predates ` +\n        `the render-count fields; re-run the capture with --remanifest so the counts match the frames`\n    );\n  }\n  if (\n    declared.renderAttempts !== counted.renderAttempts ||\n    declared.refusedRenders !== counted.refusedRenders\n  ) {\n    throw new Error(\n      `showcase-trimly: manifest turn ${n} declares ${declared.renderAttempts} render attempt(s) / ` +\n        `${declared.refusedRenders} refused, the recorded frames show ${counted.renderAttempts} / ${counted.refusedRenders} ` +\n        `— the manifest and the capture disagree, so one of them is describing a turn that did not happen`\n    );\n  }\n  if (declared.hasRender !== counted.hasRender || declared.hasUpdate !== counted.hasUpdate) {\n    throw new Error(\n      `showcase-trimly: manifest turn ${n} declares hasRender=${declared.hasRender} / hasUpdate=${declared.hasUpdate}, ` +\n        `the recorded frames show hasRender=${counted.hasRender} / hasUpdate=${counted.hasUpdate} ` +\n        `— the page would narrate a call shape the recording does not contain`\n    );\n  }\n  const firstViolation = String(refusals[0]?.data.content?.[0]?.text ?? \"\")\n    .split(\"\\n\")\n    .find((line) => line.startsWith(\"- \"));\n  if (refusals.length > 0 && !firstViolation) {\n    throw new Error(\n      `showcase-trimly: turn ${n}'s refused render carries no violation text to quote — ` +\n        `the page would print an empty quotation where the evidence should be`\n    );\n  }\n  return {\n    attempts: counted.renderAttempts,\n    refused: counted.refusedRenders,\n    succeeded: counted.renderAttempts - counted.refusedRenders,\n    hasRender: counted.hasRender,\n    hasUpdate: counted.hasUpdate,\n    carriesSlice,\n    carriesCode: typeof bootstrap?.codeB64 === \"string\",\n    carriesOverride,\n    cacheKind: okResult?.cache?.kind ?? null,\n    cacheHit: okResult?.cache?.hit ?? null,\n    firstViolation: firstViolation ? firstViolation.slice(2) : null,\n  };\n};\nexport const r1 = renderStats(turn1, 1);\nexport const r2 = renderStats(turn2, 2);\n\n{/* What the handshake negotiated, read off the two frames beat 2's panel\n    renders: what the agent's draft asked for, what the server proposed back,\n    and which gaps between them it raised. Beat 2 tells that story from these\n    values, so the next recording re-narrates itself. */}\nexport const handshakeFacts = (turn) => {\n  const kinds = frameKinds(turn.frames);\n  const draft = turn.frames.find(\n    (_, i) => kinds[i] === \"message:tool.args.assembled:ggui_handshake\"\n  )?.data.input?.blueprintDraft?.contract;\n  const resultText = String(\n    turn.frames.find((_, i) => kinds[i] === \"message:tool.done:ggui_handshake\")?.data.content?.[0]\n      ?.text ?? \"\"\n  );\n  let result = null;\n  try {\n    result = JSON.parse(resultText);\n  } catch {\n    result = null;\n  }\n  const summary = result?.suggestion?.proposedContractSummary ?? null;\n  const gaps = (result?.suggestion?.validationFindings ?? []).filter(\n    (f) => f.code === \"COVERAGE_GAP\"\n  );\n  const advice = /Default to [^,]+/.exec(gaps[0]?.message ?? \"\");\n  return {\n    draftActions: Object.keys(draft?.actionSpec ?? {}),\n    action: result?.action ?? null,\n    origin: result?.suggestion?.origin ?? null,\n    summary,\n    gapPaths: gaps.map((f) => f.path),\n    gapSeverity: gaps[0]?.severity ?? null,\n    advice: advice ? advice[0] : null,\n    // The proposal is what the agent accepted whenever it posted no override.\n    proposalDeclaresActions: summary === null ? null : !/actions=∅/.test(summary),\n  };\n};\nexport const h1 = handshakeFacts(turn1);\nexport const grid1IsReadOnly = !r1.carriesOverride && h1.proposalDeclaresActions === false;\n\n{/* Equal placeholders mean equal ids only when the capture redacted both\n    turns through ONE map, which the manifest states as redaction:\"single-map\".\n    Absent that stamp — or two session frames that disagree — the shared thread\n    is something the rig did, not something these frames evidence. */}\nexport const redaction = manifest.redaction ?? null;\n\n{/* Whether ONE placeholder map spans both turns is provable from the fixtures\n    themselves: under one map, turn 2's newly-seen ids continue turn 1's\n    numbering; under per-turn maps every kind restarts at _1, so no kind in\n    turn 2 could exceed turn 1's maximum. Derived, so the page neither claims\n    nor disclaims evidence it has. A manifest stamped redaction:\"single-map\"\n    is an additional sufficient proof, not a required one. */}\nexport const placeholderMax = (turn) => {\n  const max = new Map();\n  JSON.stringify(turn.frames).replace(/\"([a-z_]+)_(\\d+)\"/g, (whole, kind, n) => {\n    const v = Number(n);\n    if (!max.has(kind) || max.get(kind) < v) max.set(kind, v);\n    return whole;\n  });\n  return max;\n};\nexport const maxTurn1 = placeholderMax(turn1);\nexport const continuing = [...placeholderMax(turn2).entries()].filter(\n  ([kind, n]) => n > (maxTurn1.get(kind) ?? 0)\n);\nexport const sharedMap = maxTurn1.size > 0 && continuing.length > 0;\nexport const independentMaps = maxTurn1.size > 0 && continuing.length === 0;\nexport const continuityExamples = continuing\n  .map(([kind]) => `${kind}_${(maxTurn1.get(kind) ?? 0) + 1}`)\n  .sort()\n  .slice(0, 3)\n  .join(\", \");\nexport const doneThreadIdOf = (turn) => {\n  const kinds = frameKinds(turn.frames);\n  const frame = turn.frames.find((_, i) => kinds[i] === \"done\");\n  return frame?.data.threadId ?? null;\n};\nexport const threadIdOf = (turn) => {\n  const kinds = frameKinds(turn.frames);\n  const session = turn.frames.find((_, i) => kinds[i] === \"session\");\n  return session?.data.threadId ?? null;\n};\nexport const sameThread = threadIdOf(turn1) !== null && threadIdOf(turn1) === threadIdOf(turn2);\n\n{/* The session-space thread is on screen four times: both `session` frames and\n    both `done` frames, which the closing panels already select. */}\nexport const threadOnScreenFourTimes =\n  sameThread &&\n  [turn1, turn2].every((t) => doneThreadIdOf(t) !== null && doneThreadIdOf(t) === threadIdOf(t));\n\n{/* Two id spaces share the key name `threadId`: the session frame's is the\n    platform's durable conversation thread, the AgJSON stream frames carry\n    their own per-turn one. Only explain that while the recording actually\n    shows both — a wire that later unifies them must not leave a stale note. */}\nexport const streamThreadIdOf = (turn) => {\n  const kinds = frameKinds(turn.frames);\n  const frame = turn.frames.find((_, i) => kinds[i] === \"message:turn.start\");\n  return frame?.data.threadId ?? null;\n};\nexport const twoThreadSpaces = [turn1, turn2].some(\n  (t) => streamThreadIdOf(t) !== null && streamThreadIdOf(t) !== threadIdOf(t)\n);\n\n:::note[What you are looking at]\nRecorded **{new Date(manifest.capturedAt).toISOString().slice(0, 10)}** against protocol **<code>{manifest.protocolSchemaVersion}</code>**, from guuey's release-tier demo agent _Trimly_ — a fictional booking SaaS running at <code>{manifest.endpointHost}</code> (app <code>{manifest.agentAppId}</code>). guuey is a separate hosting platform that speaks the GGUI protocol; this page is about the **protocol**, and the frames are the evidence. Ids are stable placeholders; credentials were removed at capture, never recorded.\n:::\n\nTwo turns, one thread, two rendered surfaces. Read the story; open any **On the wire** panel to see exactly what crossed the wire at that beat. Nothing on this page is typed by hand: each panel is read out of the committed recording at build time, and a panel that names a frame the recording does not contain fails the build rather than quietly showing you prose.\n\n## 1 · The ask\n\nA visitor — anonymous, no account — types _\"{turn1.prompt}\"_. The rig behind this recording invoked the agent the way that visitor would: over the host's guest wire, with no sign-in and no bearer token. One POST goes out, and the first frame back is the `session`, which is where that shows up on the wire: `authMode: \"anonymous\"`, a caller id standing in for the guest hash the real exchange carried (placeholdered here like every other id), and the `threadId` that names the durable thread every later turn replays against.\n\n<WireFrames showcase=\"trimly\" turn={1} select={[\"session\"]} />\n\n## 2 · Handshake — the contract, negotiated\n\nBefore any UI exists, the agent calls **`ggui_handshake`** with an _intent_ and a _draft contract_. The draft is the interesting half: the agent describes, in schema, the surface it wants. The server answers with a **proposed** contract, an `action` (here <code>{h1.action}</code>), and an `origin` — `cache` (a similar UI was built before and is reused), `agent` (the draft was already clean), or `synth` (the server repaired it). Open the panel to read the server's rationale: which blueprint it matched, at what confidence, and every coverage gap between the draft and the cached surface.\n\nThe contract is where the protocol's placement rule lives: `actionSpec` = discrete events that drive the agent's next turn, `contextSpec` = observable state the agent reads when it next works, and there is no third category — see [the placement test](/glossary/#contract) and [Envelopes](/protocol/envelopes/).\n\n{h1.draftActions.length > 0 && h1.gapPaths.length > 0 ? (\n\n  <p>\n    Watch what the two frames in the panel do with that. This ask wanted an action: the draft declares{\" \"}\n    <code>actionSpec.{h1.draftActions[0]}</code> alongside its props. The contract the server proposed —\n    reused from a cached blueprint — carries no such thing: <code>{h1.summary}</code>. {h1.gapPaths.length === 1 ? \"The difference comes\" : \"The differences come\"}\n    {\" \"}back as {h1.gapPaths.length === 1 ? \"one \" : `${h1.gapPaths.length} `}\n    <code>COVERAGE_GAP</code> finding{h1.gapPaths.length === 1 ? \"\" : \"s\"} at <code>{h1.gapSeverity}</code>\n    {h1.gapPaths.length > 1 ? <> — {h1.gapPaths.map((p, i) => <><code key={p}>{p}</code>{i < h1.gapPaths.length - 1 ? \", \" : \"\"}</>)} — </> : \" \"}\n    never a veto{h1.advice ? <>, with the server's own counsel attached: \"{h1.advice}\"</> : null}. The agent\n    accepted, so the grid this recording mounted is a read-only card. Re-aiming the contract instead is a\n    call the protocol gives it — beat 6 is where that lever appears.\n  </p>\n) : null}\n\n<WireFrames\n  showcase=\"trimly\"\n  turn={1}\n  select={[\n    \"message:tool.start:ggui_handshake\",\n    \"message:tool.args.assembled:ggui_handshake\",\n    \"message:tool.done:ggui_handshake\",\n  ]}\n/>\n\nOne detail the raw frames show and this page smooths over: hosts may namespace MCP tool names. In these recordings the wire name is `mcp__ggui__ggui_handshake`; the protocol name is `ggui_handshake`, and readers of the stream should match on the bare name.\n\n## 3 · Render — and the contract check that refuses a bad one\n\n**`ggui_render`** commits the contract and returns the view's **identity** — not its material. On this host's stream that arrives as a `tool.done` frame carrying the render's JSON both as text and as the result's `structuredContent`: `resourceUri`, the view's durable `ui://` identity; `action`, reporting whether the surface was created or reused; `contractHash`; and a `cache` record. What is deliberately **not** on the wire is the mount material — the bootstrap that boots the runtime and opens the live channel, the compiled component, the theme. A host resolves those by reading `resourceUri` (MCP `resources/read`), the read-plane-only posture: the tool result names the view, the read plane serves it — see [Bootstrap handshake](/protocol/bootstrap-handshake/).\n\nNote what the agent never writes: component code. Its call is `handshakeId` plus `props` — and, when it wants to re-aim the contract it was offered, an `override` — but read the assembled-arguments frame in the panel and you will not find markup, styling, or a component anywhere in it. Authoring the UI is not the agent's job.\n\n{!r1.carriesSlice ? (\n  <p>\n    So where is the code? Not in the panel — and that is the point. Under the read-plane-only posture\n    the tool result carries no <code>ai.ggui/render</code> slice at all: the host takes the{\" \"}\n    <code>resourceUri</code> above to MCP <code>resources/read</code> and receives the shell — the bootstrap\n    (which runtime to load, which live channel to open), the compiled component, the theme — from the read\n    plane, cached and content-addressed. The agent's stream stays small and identity-shaped; the multi-kilobyte\n    material never rides the conversation. It was generated server-side, from the contract; the agent neither\n    wrote it nor saw it.\n  </p>\n) : r1.carriesCode ? (\n  <p>\n    The code lives in the material coming back. This recording's <code>ai.ggui/render</code> slice hands the\n    host a compiled component as <code>codeB64</code> alongside <code>propsJson</code> and <code>theme</code> —\n    that is what makes the view mountable without a second fetch, and it is why the frame in the panel is\n    several kilobytes of base64. It was generated server-side, from the contract; the agent neither wrote\n    it nor saw it.\n  </p>\n) : (\n  <p>\n    In this recording the <code>ai.ggui/render</code> slice carries no <code>codeB64</code> — open the panel\n    to see what it does hand the host, and how the host is expected to resolve the view from it.\n  </p>\n)}\n\nA negotiated contract is enforced, not advised. When the props an agent commits violate the contract it just agreed to, the render is **refused** with a typed error naming the exact offending path, and the agent repairs the call and renders again — no human in the loop. That is the check working, not the agent failing.\n\n{r1.refused > 0 ? (\n\n  <p>\n    In this turn the check fired. The panel below carries every render call and result in wire order:\n    the {r1.refused === 1 ? \"first result is a refusal\" : `first ${r1.refused} results are refusals`}\n    {r1.succeeded > 0\n      ? \", and the last is the success\"\n      : \", and every call was refused — the surface never mounted\"}. The violation is worth reading in\n    full — it names a path and the vocabulary the contract declared:{\" \"}\n    <code>{r1.firstViolation}</code>. Other violations take the same shape: a field the contract never\n    declared, a string where an object was specified, each reported by path.\n  </p>\n) : (\n  <p>\n    In this turn the check had nothing to refuse: the agent's props conformed on the first call. The panel\n    below is that single exchange — the call it assembled, and the result it got back.\n  </p>\n)}\n\n<WireFrames\n  showcase=\"trimly\"\n  turn={1}\n  select={[\"message:tool.args.assembled:ggui_render\", \"message:tool.done:ggui_render\"]}\n/>\n\n## 4 · The view boots\n\nThe host mounts the material; the runtime inside the frame answers the MCP-Apps `ui/initialize` handshake, posts `ggui:renderer-ready`, and opens the live WebSocket named in the bootstrap. This beat happens **inside the host page**, not on the agent's SSE stream — which is why there is no wire panel here: the recorded stream shows the agent's side only. The exact boot flow and host obligations are specified in [Bootstrap handshake → the boot flow](/protocol/bootstrap-handshake/#the-boot-flow).\n\n## 5 · The gesture (and how a real one arrives)\n\nA surface that declares an action routes a tap this way: the visitor taps, the runtime emits an `ActionEnvelope` on the live channel, and the agent drains it with **`ggui_consume`** and reasons about it on its next turn — see [Envelopes → ActionEnvelope](/protocol/envelopes/#actionenvelope).\n\n{grid1IsReadOnly ? (\n\n  <p>\n    This grid declares none. Beat 2's coverage gap is why: the agent asked for an action, the cached\n    contract it accepted has no <code>actionSpec</code> at all, so there is nothing here to tap. Whether the live\n    demo negotiates you the same read-only card or one with an action is its own handshake's call — this\n    page speaks only for the recording. The recording was also made by a script that sent no gesture, so\n    no <code>ggui_consume</code> frame exists and nothing on this page claims one does. What the frames do\n    show is the end of the turn: the agent hands control back with a next step, offering to take the\n    conversation further.\n  </p>\n) : (\n  <p>\n    This recording was made by a script that sent no gesture, so there is no <code>ggui_consume</code> frame\n    to show and nothing here claims one happened. What the frames do show is the end of the turn: the agent\n    hands control back with a next step, offering to take the conversation further.\n  </p>\n)}\n\n<WireFrames\n  showcase=\"trimly\"\n  turn={1}\n  select={[\"message:text.delta\", \"done\"]}\n  title=\"On the wire — the turn closes\"\n/>\n\n## 6 · Turn 2 — a second ask on the same thread\n\n_\"{turn2.prompt}\"_ The invoke carries the `threadId` learned in beat 1, so the agent has the whole conversation. The panel below opens turn 2 with its own `session` frame — {sameThread && sharedMap ? (<>naming the same <code>{threadIdOf(turn2)}</code> you read in beat 1, and the numbering is what makes that evidence rather than coincidence: one placeholder map spans the whole recording, so turn 2's ids <em>continue</em> turn 1's instead of restarting at <code>_1</code> ({continuityExamples}). Equal placeholders therefore mean equal ids{threadOnScreenFourTimes ? <>, and this thread is on screen four times across the panels on this page — both <code>session</code> frames and both <code>done</code> frames, all agreeing</> : null}</>) : sameThread && redaction === \"single-map\" ? (<>naming the same <code>{threadIdOf(turn2)}</code> you read in beat 1, which this capture stamps as redacted through one placeholder map (<code>redaction: \"single-map\"</code>), so equal placeholders mean equal ids</>) : independentMaps ? (<>the rig replayed the <code>threadId</code> it learned in turn 1; this recording's turns were redacted through independent maps, each restarting at <code>_1</code>, so equal placeholders are not evidence of it here</>) : (<>the rig replayed the <code>threadId</code> it learned in turn 1</>)}. The handshake that follows it negotiates a contract for the new ask.\n\n<WireFrames\n  showcase=\"trimly\"\n  turn={2}\n  select={[\"session\", \"message:tool.start:ggui_handshake\", \"message:tool.done:ggui_handshake\"]}\n  title=\"On the wire — turn 2 opens\"\n/>\n\n{twoThreadSpaces ? (\n\n  <p>\n    One caution if you open the raw fixtures: two different id spaces share the key name{\" \"}\n    <code>threadId</code>. The <code>session</code> frame's is the platform's durable conversation thread —\n    the one a replayed turn rejoins, and the one every claim on this page is about. The message-stream\n    frames (<code>turn.start</code>, <code>message.start</code>) carry the AgJSON stream's own per-turn\n    thread id under the same key: <code>{streamThreadIdOf(turn1)}</code> in turn 1 and{\" \"}\n    <code>{streamThreadIdOf(turn2)}</code> in turn 2, neither of them{\" \"}\n    <code>{threadIdOf(turn1)}</code>. Different spaces, same spelling; compare session frames to session\n    frames.\n  </p>\n) : null}\n\n{r2.hasUpdate ? (\n\n  <>\n    <p><strong>This recording mutates the mounted surface.</strong> The agent called <code>ggui_update</code>: the props were swapped and fanned out to the live view, plus a new history card and an advanced epoch. No fresh <code>ggui_render</code>, so the view is never replaced. See the callout below for how that differs from <code>ggui_amend</code>.</p>\n    <WireFrames showcase=\"trimly\" turn={2} select={[\"message:tool.args.assembled:ggui_update\", \"message:tool.done:ggui_update\"]} />\n  </>\n) : r2.hasRender ? (\n  <>\n    <p><strong>This recording shows a second render.</strong> The agent negotiated again — a second <code>ggui_handshake</code> on the same thread — and rendered a fresh comparison surface. That is a legitimate choice for a <em>different</em> surface: a two-option slot comparison is not the week grid. The contract check from beat 3 applies here too.</p>\n    {r2.refused > 0 ? (\n      <p>The panel is again in wire order: the {r2.refused === 1 ? \"first result is a refusal\" : `first ${r2.refused} results are refusals`}{r2.succeeded > 0 ? \", the last is the success\" : \", and every call was refused — this surface never mounted\"}.</p>\n    ) : (\n      <p>This time nothing was refused — the agent's props conformed on the first call, so the panel holds one call and one successful result. Beat 3 is where you can see the check turn a call back.</p>\n    )}\n    <WireFrames showcase=\"trimly\" turn={2} select={[\"message:tool.args.assembled:ggui_render\", \"message:tool.done:ggui_render\"]} title=\"On the wire — turn 2 renders\" />\n    {r2.carriesOverride ? (\n      <p>\n        This render call carries a third field{!r1.carriesOverride ? \" the turn-1 call did not\" : \"\"}:{\" \"}\n        <code>override</code>. That is\n        step 3 of the handshake — the agent takes the proposed contract as a starting point and re-aims\n        it, posting the contract it actually wants (open <code>override.contract</code> in the panel).\n        An override re-resolves the render's effective identity{r2.cacheHit === false ? (\n          <>, so the server had nothing stored under it: this render reports <code>cache: {\"{\"}hit: false, kind: \"{r2.cacheKind}\"{\"}\"}</code> and the component was generated fresh</>\n        ) : r2.cacheHit === true ? (\n          <>, and this one still landed on something stored: <code>cache: {\"{\"}hit: true, kind: \"{r2.cacheKind}\"{\"}\"}</code></>\n        ) : null}\n        {!r1.carriesOverride && r1.cacheHit === true\n          ? ` — where turn 1, which posted no override and accepted the proposal as-is, reused a stored blueprint (${r1.cacheKind})`\n          : \"\"}\n        . Reuse is the reward for accepting a proposal; re-aiming it can cost a generation, and the agent\n        gets to decide which it wants.\n      </p>\n    ) : null}\n    <aside class=\"callout\">\n      <p><strong>What it would look like if the agent mutated the mounted surface instead.</strong> When the right move is to change what an already-mounted view shows — the same grid, different week — the rule is <em>never re-render to mutate</em>. The agent calls <a href=\"/api/mcp-protocol/\"><code>ggui_amend</code></a>: no generation runs, the props are swapped and pushed down the live channel, and the card the user is looking at updates in place — no new card, the history number does not advance, and it updates <em>without losing scroll position, focus, or uncommitted input</em>. <code>ggui_update</code> is that same swap plus a new history card, and it advances the epoch. Either way the wire shape is one message — a <a href=\"/api/websocket-protocol/#props_update\"><code>props_update</code></a> on the live WebSocket carrying <code>{\"{sessionId, props, epoch}\"}</code>, a full props replacement rather than a patch. The agent-side loop is written up in <a href=\"/how-it-works/\">How it works</a>. This recording exercises neither call; nothing here claims it does.</p>\n    </aside>\n  </>\n) : (\n  <p><strong>In this recording turn 2 was answered in prose</strong> — the agent judged no new surface was needed. The frames below are the turn as recorded. Mutating an already-mounted view is a different call, <code>ggui_amend</code> (or <code>ggui_update</code>, which also mints a history card and advances the epoch); both fan out as a <a href=\"/api/websocket-protocol/#props_update\"><code>props_update</code></a> on the live channel, and this recording does not exercise either.</p>\n)}\n\n<WireFrames\n  showcase=\"trimly\"\n  turn={2}\n  select={[\"message:text.delta\", \"done\"]}\n  title=\"On the wire — turn 2 closes\"\n/>\n\n## 7 · What a conforming implementation had to do\n\nAt each beat above, an implementation either did the specified thing or the turn would not have completed: answered the handshake with a conforming contract (beat 2), validated the committed props against that contract before mounting anything (beat 3), returned a durable identity the host resolves to mount material through the read plane (beat 3), booted and negotiated inside the frame (beat 4), and — for a surface that declares an action — would route a gesture back as an `ActionEnvelope` (beat 5).\n\n{r1.refused + r2.refused > 0 ? (\n\n  <p>\n    Validation is the one of those you can watch happen rather than take on faith:{\" \"}\n    {r1.refused + r2.refused === 1 ? \"one call in this recording was\" : `${r1.refused + r2.refused} calls in this recording were`}{\" \"}\n    refused for violating the contract\n    {(() => {\n      const repaired = [\n        r1.refused > 0 && r1.succeeded > 0 ? \"beat 3's panel\" : null,\n        r2.refused > 0 && r2.succeeded > 0 ? \"beat 6's panel\" : null,\n      ].filter(Boolean);\n      if (repaired.length === 0) return \", and no repaired call followed — the refused surface never mounted\";\n      if (repaired.length === 1) return `, and the repair that followed is in ${repaired[0]}, one frame later`;\n      return `, and the repairs that followed are in ${repaired.join(\" and \")}, each one frame later`;\n    })()}.\n  </p>\n) : (\n  <p>\n    No call was refused anywhere in this recording — every render conformed on its first attempt, so\n    validation is the one obligation above that you have to take on faith here. Beat 3 describes the shape\n    a refusal takes when it happens.\n  </p>\n)}\n\nThe arbiter of \"conforming\" is not this page — it is the [conformance kit](/protocol/conformance/); the fixtures behind these panels are the kind of input it runs.\n\n## 8 · Run it yourself, check yours\n\n- **See it live:** [guuey.com/demos/scheduler](https://guuey.com/demos/scheduler) — run the same ask and watch a negotiation of your own.\n- **Check your implementation:** take the recording itself — [turn 1](/showcase/trimly/turn-1.frames.json), [turn 2](/showcase/trimly/turn-2.frames.json), and its [manifest](/showcase/trimly/manifest.json) — and feed the frames to your host or server as fixtures. They are the same files these panels are built from, ids placeholdered, nothing else altered.\n- **Build your own agent on the protocol:** [Build an agent on hosted GGUI](/quickstart/hosted-agent/) or [self-host](/quickstart/self-hosted/).\n\n<style>{`\n  .callout { border-left: 3px solid var(--sl-color-accent); padding: 0.25rem 1rem; margin: 1rem 0; background: var(--sl-color-gray-6); border-radius: 0 0.5rem 0.5rem 0; }\n`}</style>"},{"path":"/protocol/version-policy.md","title":"Version policy","description":"Major / minor / patch semantics for the GGUI protocol, deprecation timeline, support matrix, and CI enforcement.","body":"---\ntitle: Version policy\ndescription: Major / minor / patch semantics for the GGUI protocol, deprecation timeline, support matrix, and CI enforcement.\n---\n\n> **Semver promise:** a change to `@ggui-ai/protocol` is breaking if-and-only-if a consumer built against the prior version now fails the conformance kit.\n\nThis page defines \"major\", \"minor\", and \"patch\" on the GGUI protocol, how deprecation windows work, which server ↔ client pairs are supported, and how a breaking change is migrated.\n\nThe canonical version constant is `PROTOCOL_SCHEMA_VERSION` (an alias of `PROTOCOL_VERSION` in [`@ggui-ai/protocol`](https://github.com/ggui-ai/ggui/blob/main/packages/protocol/src/version.ts)). Current value: `draft-2026-09-10`. Wire-level negotiation runs through the schema-version handshake; mismatches surface as the `UPGRADE_REQUIRED` error. The current server default is `versionPolicy: 'reject'` — on mismatch the server emits `UPGRADE_REQUIRED` and closes the connection; `'advisory'` is a legacy opt-out that keeps it open.\n\n---\n\n## 1. Semver semantics applied to the protocol\n\nThe protocol follows semver. What counts as each bump kind is defined by the **conformance kit** ([`packages/protocol-conformance`](https://github.com/ggui-ai/ggui/tree/main/packages/protocol-conformance)), not by surface-syntax changes in the `@ggui-ai/protocol` package.\n\n### Major (N → N+1)\n\nA bump is **major** iff at least one conformance-kit fixture that passed against version N now fails against version N+1 when the only change is the protocol version.\n\nExamples of changes that are major:\n\n- Renaming a **reserved channel** (e.g., `_ggui:preview` → `_ggui:assembly`).\n- Removing a canonical live-channel error code that the kit asserts (e.g., deleting `CONTRACT_VIOLATION`).\n- **Narrowing** an extensibly-closed union (e.g., collapsing `SubmitActionKind` variants so previously-recognized kinds now fail validation).\n- Changing the shape of an **envelope** (`ActionEnvelope`, `StreamEnvelope`) in a way that invalidates fixtures emitted by prior-version producers — required-field additions, required-field renames, required-field type changes.\n- Tightening a `MUST` / `MUST NOT` clause in the spec such that prior-conformant implementations become non-conformant.\n- Changing handshake semantics in a way that rejects a subscribe that would previously have succeeded (e.g., the `versionPolicy` default flip `advisory` → `reject` — already shipped on 2026-04-24 as a pre-launch config-default change with its own release note; post-v1.0, a flip of this shape is major).\n- Removing or replacing a **transport binding** previously declared conformant.\n\n### Minor (N.x → N.(x+1))\n\nA bump is **minor** iff every conformance-kit fixture that passed against N.x still passes against N.(x+1), and the delta is additive.\n\nExamples of changes that are minor:\n\n- Adding a new **`channel_error` code** literal (consumers that don't recognize it degrade per the extensibly-closed union rule).\n- Adding a new **observability event kind** to the renderer's `ggui:observe` postMessage union (extensibly-closed — see the [Bootstrap handshake](/protocol/bootstrap-handshake/) page).\n- Adding an **optional field** on an envelope. Pre-existing consumers see `undefined` and MUST behave as before.\n- Adding a new **reserved channel** whose absence is not a failure mode for prior-version consumers.\n- Adding a new **canonical live-channel error code** (the `code` field is typed open).\n- Adding a new **transport binding** (e.g., stdio MCP, HTTP long-poll) while keeping WebSocket canonical.\n\n### Patch (N.x.y → N.x.(y+1))\n\nA bump is **patch** iff the only changes are documentation clarifications, fixture additions that do not change assertions on prior-version consumers, or internal implementation adjustments to first-party packages that do not affect the wire or the kit.\n\nExamples:\n\n- Spec prose clarifications that close a reader-ambiguity without changing obligations.\n- New conformance-kit fixtures that assert behavior already required by the prior version's spec text.\n- Bug fixes in first-party servers / clients that bring them back into conformance (the bar was always there — the code now honors it).\n\n### The `draft-` prefix\n\nPre-v1 versions use a `draft-` prefix (e.g., `draft-2026-06-12`). While `draft-`, the semver rules above describe **intent**, not **obligation** — the protocol reserves the right to ship breaking changes without a migration doc until the first stable release tags `v1.0`.\n\n**Rule flip at v1.0:** once `PROTOCOL_SCHEMA_VERSION` drops the `draft-` prefix, every major bump MUST have a migration doc (CI-enforced) and every deprecation MUST honor the window described in §3.\n\n---\n\n## 2. Breaking change definition — the kit is the arbiter\n\n> **A change is breaking if-and-only-if a consumer built against the prior version now fails the conformance kit.**\n\nAnchor every future \"is this breaking?\" debate to a kit run. Opinion, intent, and prose-level spec reading are NOT the arbiter — the kit is.\n\nOperational consequences:\n\n1. If you're unsure whether a PR is breaking, **run the kit** against the prior-version tag and against the PR branch. If fixtures that passed before now fail, the PR is major.\n2. If the kit passes but you \"feel\" the change is risky, **add a fixture** that captures the worry. Either the fixture passes (the change is not breaking) or it fails (the change is breaking and you just found out) — both outcomes are productive.\n3. If you think a change is major but the kit disagrees, the kit has a gap. Patch the kit in the same PR; the patched kit decides.\n\nThis anchor means every change's breaking-ness is **observable and reproducible**, not a matter of taste.\n\n---\n\n## 3. Deprecation timeline\n\nDeprecation is the only graceful path from a minor-version ship to a major-version removal.\n\n### The window\n\n- **v(N)** — the version in which a surface is first tagged `@deprecated` (in TSDoc) AND documented in the release notes as deprecated. The surface MUST continue to work identically to the prior version; `@deprecated` is a signal to consumers, not a behavior change.\n- **v(N+1)** — deprecated surface still works. Callers SHOULD migrate in this window. Release notes repeat the deprecation warning.\n- **v(N+2)** — removal is allowed here at the earliest. Removal is a major bump, so this version is v(N+2) = major. The two-minor window guarantees consumers saw at least one full minor cycle with the `@deprecated` warning before removal.\n\n### Minimum\n\n**At least two minor versions MUST elapse between `@deprecated` and removal.** If N is the first deprecated version and N+2 is the major that removes it, the intermediate v(N+1) minor MUST ship. Shorter windows (v(N) deprecated → v(N+1) removed) are NOT allowed for non-security changes.\n\n### Security-fix escape hatch\n\nA **critical security fix** MAY skip the window — shipping a breaking change without a prior `@deprecated` minor — if and only if:\n\n1. The release note explicitly names the CVE or security class.\n2. The release note explains why the window was skipped.\n3. A migration doc per §5 ships alongside the release.\n\nNon-security urgency (e.g., \"this mistake is embarrassing\") is NOT grounds for skipping the window.\n\n### What \"deprecated\" means on the wire\n\nThe protocol does not have a wire-level \"deprecated field\" marker. Deprecation lives in TSDoc on the `@ggui-ai/protocol` types and in the release notes. Consumers parsing the wire cannot detect a field is `@deprecated` from the frame alone — they learn it from the package changelog.\n\n### Policy-default flips count as breaking\n\nChanging a **default** that the wire handshake depends on is breaking under §2 because consumers that relied on the prior default observably fail against the new default. The canonical example already happened pre-launch: the `versionPolicy` default flip `advisory` → `reject` shipped on 2026-04-24 as a config-default change with its own release note. Post-v1.0, a flip of that shape MUST use the same window: ship the new value as an opt-in in v(N), document the flip in the release notes, then flip the default in v(N+2).\n\n---\n\n## 4. Version support matrix\n\nWhich server versions support which client versions. Populated at launch (v1.0); the row below is illustrative — the protocol is pre-v1 (`draft-2026-09-10`) today:\n\n| Server `PROTOCOL_SCHEMA_VERSION` | Min client | Max client | Status                   | EOL date |\n| -------------------------------- | ---------- | ---------- | ------------------------ | -------- |\n| _`1.0.x`_                        | _`1.0.0`_  | _`1.0.x`_  | _current (illustrative)_ | —        |\n\n### Column meanings\n\n- **Server version** — the `PROTOCOL_SCHEMA_VERSION` the server emits in the subscribe-ack payload's `serverVersion`.\n- **Min client / Max client** — the lowest / highest entries in the client's `CLIENT_SUPPORTED_VERSIONS` set guaranteed to subscribe successfully.\n- **Status** — one of:\n  - `current` — actively maintained; all fixes land here.\n  - `security-only` — no new features; only security patches.\n  - `EOL` — no longer maintained. Servers on EOL versions MAY refuse to start.\n- **EOL date** — the date `current` transitions to `security-only`, or `security-only` transitions to EOL.\n\n### Matrix update cadence\n\n- New **minor** ship → append a row; prior row may update Max client.\n- New **major** ship → append a new row; prior-major row transitions to `security-only` for at least 6 months before EOL.\n- Security-only → EOL → at least 6 additional months.\n\n### Out-of-matrix clients\n\nIf a client targets a version outside the matrix, the server emits `UPGRADE_REQUIRED`. Whether the connection stays open depends on the server's `versionPolicy` at the time.\n\n---\n\n## 5. Migration-guide requirement\n\nEvery major bump MUST ship a migration doc at `docs/protocol/migrations/v<N>-to-v<N+1>.md`. (Current pre-v1 drafts use date-named migration docs — e.g. `2026-06-05-gguisession-reintroduction.md`; the `v<N>-to-v<N+1>.md` pattern applies from v1.0.) The CI check (§7) enforces the file's existence; content follows a standard template with required sections:\n\n- **What changed** — 1–2 paragraph summary.\n- **Why** — motivation (conformance-kit failure mode that drove the change).\n- **Breaking-change summary** — bullet list of renamed / removed / tightened surfaces.\n- **Migration steps per consumer kind** — fixture authors, ConformanceHost implementers, agent builders, SDK consumers.\n- **Timeline** — dates for v(N) deprecation ship, v(N+1) soft cut, v(N+2) removal.\n- **Rollback procedure** — how to pin to prior version if a consumer can't migrate fast enough.\n\n---\n\n## 6. Release cadence expectations\n\nThese are targets, not promises:\n\n- **Minor (N.x → N.(x+1)):** roughly monthly. Additive adjustments driven by conformance-kit fixture gaps, new observability events, or new canonical error codes tend to accumulate on this cadence. Empty minor cycles are skipped rather than force-shipped.\n- **Major (N → N+1):** annual or slower. Rare and pre-announced. A major ship is a disruption event for every downstream — the protocol leans hard on minor + deprecation windows to defer majors.\n- **Patch (N.x.y → N.x.(y+1)):** ad hoc. Doc corrections, fixture additions that tighten existing assertions, and first-party impl bug fixes ship when ready.\n\nAnnouncements ship in the version history maintained in [`@ggui-ai/protocol`'s `version.ts`](https://github.com/ggui-ai/ggui/blob/main/packages/protocol/src/version.ts) and in the public protocol release notes.\n\n---\n\n## 7. CI enforcement\n\nTwo workflows guard this policy:\n\n- **Spec drift** — ensures spec envelope code blocks match the `@ggui-ai/protocol` types. Catches structural drift that would otherwise ship as silent breakage.\n- **Version migration** — reads `PROTOCOL_SCHEMA_VERSION` on the PR head and on the base ref; if the **major** component changed, asserts the migration doc exists in the PR diff.\n\nThe second check is the policy's teeth: you cannot ship a major bump without the migration doc the policy requires.\n\n---\n\n## Appendix: Worked examples\n\n### A.1 Adding a new live-channel error code\n\n**Scenario:** a new failure mode — `TOOL_DENIED` — needs to land alongside the existing canonical literals on the WS `error` / `channel_error` frame.\n\n- **Bump kind:** minor. Prior-version consumers that don't know `TOOL_DENIED` see an extensibly-closed (`code: string`) value and MUST degrade gracefully. No kit fixture asserting the prior closed set should fail.\n- **Required work:** add the constant to `@ggui-ai/protocol`, document it as a canonical live-channel error-code literal, add a conformance-kit fixture asserting the new code round-trips.\n- **Migration doc:** none needed (not a major bump).\n\n### A.2 Removing a canonical error-code literal\n\n**Scenario:** `SCHEMA_MISMATCH_ERROR` is merged into `CONTRACT_VIOLATION` with a structured `causedBy`.\n\n- **Bump kind:** major. A consumer that pattern-matches on `SCHEMA_MISMATCH_ERROR` now never sees it, fails its fixture.\n- **Required work:** first ship `@deprecated SCHEMA_MISMATCH_ERROR` in v(N), continue emitting both `SCHEMA_MISMATCH_ERROR` and the new shape during v(N+1), then remove `SCHEMA_MISMATCH_ERROR` emission in v(N+2).\n- **Migration doc:** `vN-to-v(N+1).md` (enforced by CI on the major-bump PR). Covers the `causedBy` shape, fixture changes, and consumer-side pattern-match migration.\n\n### A.3 Renaming a reserved channel\n\n**Scenario:** `_ggui:preview` → `_ggui:assembly` for parity with transport-layer conventions.\n\n- **Bump kind:** major. Every consumer that subscribes to the old name now fails. Every fixture that references the old name needs updating.\n- **Required work:** can NOT ship via deprecation (reserved-channel names are matched verbatim). Must bundle with other breaking changes into the next major. Migration doc is non-trivial — every producer AND consumer changes.\n- **Operational note:** changes of this shape are exactly why the protocol prefers extensibly-closed unions + additive fields + separate channels over in-place renames.\n\n### A.4 Clarifying a spec MUST\n\n**Scenario:** the spec's `refresh` semantics paragraph has two readings; the kit's fixture matched only one.\n\n- **Bump kind:** patch IF the kit's existing assertion already covered the intended reading (prose clarifies code); minor IF the clarification surfaces a new required behavior that prior-version impls weren't necessarily honoring (new assertion); major IF the clarification tightens behavior such that previously-conformant impls now fail.\n- **Test:** run the kit against the prior-version tag. If it passes, the clarification is patch/minor. If it fails, it was major all along — the original spec text was ambiguous and shipping the clarification is a breaking change.\n\n---\n\n## See also\n\n- [Protocol overview](/protocol/overview/) — three-channel topology and reference implementation.\n- [Conformance kit](https://github.com/ggui-ai/ggui/tree/main/packages/protocol-conformance) — the test suite this policy treats as the arbiter.\n- [Conformance](/protocol/conformance/) — the 4-criteria contract bar + 6-criteria protocol bar this policy gates."},{"path":"/quickstart/claude-desktop.md","title":"Use GGUI in Claude Desktop","description":"Connect Claude Desktop to hosted GGUI and get generated UI in your chats in about three minutes — no code, no infrastructure.","body":"---\ntitle: Use GGUI in Claude Desktop\ndescription: Connect Claude Desktop to hosted GGUI and get generated UI in your chats in about three minutes — no code, no infrastructure.\n---\n\nClaude answers in text. With GGUI connected, it can answer with a real interface — a form, a table, a picker — right inside the chat. Setup takes about three minutes and never leaves your browser and Claude Desktop. You don't write any code and you don't open a terminal.\n\n## Step 1: Create your GGUI account\n\nGo to [console.ggui.ai](https://console.ggui.ai) and sign in with Google, GitHub, or email. That is your whole signup.\n\nNew accounts start with a **one-time welcome credit** while it is available — no card — and render on it at the published per-render rates; the amount and the rates are on [ggui.ai/pricing](https://ggui.ai/pricing). When it is spent, top up on the console's credits page or bring your own provider key — see [Credits, billing & BYOK](/hosted/billing/).\n\n## Step 2: Add GGUI to Claude Desktop\n\nIn Claude Desktop, open **Settings → Connectors → Add custom connector**, and paste this address:\n\n```\nhttps://mcp.ggui.ai\n```\n\nLeave the authentication fields blank, then save. Claude handles the sign-in for you on first use.\n\n## Step 3: Approve the connection\n\nThe first time Claude uses GGUI — or as soon as you click **Connect** on the new connector card — a browser window opens on the GGUI console.\n\n1. Sign in with the same account you created in Step 1.\n2. Click **Approve**.\n\nThe console creates the access key for you and hands it to Claude Desktop automatically. There is nothing to copy and nothing to paste. The browser window closes on its own, and the connector card shows as connected.\n\nYou can see that key later on [the connector keys page](/hosted/connector-keys/). Revoking one is a command-line step for now, and that page walks through it.\n\n## Step 4: Ask for something you'd rather click than type\n\nOpen a new chat and ask for something that wants an interface. For example:\n\n> Plan my week — give me a form to rank these five tasks: inbox triage, dentist, Q3 draft, gym, call Mum.\n\nClaude decides an interface is the better answer, asks GGUI to build one, and the form appears inline in the chat. It is generated for that request, not picked from a template library, so the first one takes a while: tens of seconds, not instant. Leave it running; asking for the same kind of thing again later is much faster, because GGUI reuses what it built.\n\n**Text instead of a card?** If Claude answered in plain words, ask again and name the interface (\"show it as a form\"). If it called GGUI and still no card appears, check that the connector card shows as connected and that Claude Desktop is up to date: a card needs a host that can mount it. [Troubleshooting](/clients/claude-desktop/#troubleshooting) covers the rest.\n\n## Step 5: Use it, and let Claude read your answer\n\nDrag the rows, tick the boxes, hit submit. Your input travels straight back to Claude as structured data, exactly as if you had typed it out — so Claude can act on it in the very next message.\n\nAsk for a variation and you get a new interface. Ask for the same kind of thing again later and it comes back faster, because GGUI reuses what it built the first time.\n\n:::note[Running your own server instead?]\nEverything above uses hosted GGUI at `mcp.ggui.ai`. If you would rather run the server yourself, on your own machine and your own model key, start with the [Self-hosted quickstart](/quickstart/self-hosted/).\n:::\n\n## Where to next\n\n- **[Todos playground](/hosted/playgrounds/todos/)** — a live surface to try; one more connector to add, same three-minute ceremony.\n- **[Build an agent on hosted GGUI](/quickstart/hosted-agent/)** — put generated UI in front of your own users, in your own agent.\n- **[The GGUI console](/hosted/console/)** — manage your keys, credits, and apps.\n- **[Claude Desktop reference](/clients/claude-desktop/)** — other hosts, disconnecting, and what to do if nothing renders.\n- **[Trust & security](/hosted/trust/)** — what GGUI stores, what reaches a model, and how to delete your account."},{"path":"/quickstart/hosted-agent.md","title":"Build an agent on hosted GGUI","description":"Ship your first agent UI against hosted GGUI (mcp.ggui.ai) in under 5 minutes — no infrastructure required.","body":"---\ntitle: Build an agent on hosted GGUI\ndescription: Ship your first agent UI against hosted GGUI (mcp.ggui.ai) in under 5 minutes — no infrastructure required.\n---\n\nNew accounts start with a **one-time welcome credit** while it is available — no card — and render on it at the published per-render rates (the amount and the rates are on [ggui.ai/pricing](https://ggui.ai/pricing)). When it is spent, top up on the console's credits page, or bring your own provider key — see [Credits, billing & BYOK](/hosted/billing/).\n\n:::tip[Zero-wrapper architecture]\nGGUI has **no client SDK wrapper**. Your agent talks to `mcp.ggui.ai` directly over MCP — either via the Claude Agent SDK's native `mcpServers` config (Pattern A below) or via `@modelcontextprotocol/sdk` for any other LLM. See [the Claude agent example](/examples/claude-agent/) for the canonical reference implementation.\n:::\n\nIn 5 minutes, your agent will negotiate a UI contract and put a generated form on the wire as an MCP-Apps resource — no React code, no front-end build, no infrastructure.\n\nOne thing to know before you start, because it shapes what you see at the end: a render is a resource, not a URL, and something has to **mount** it before a human can fill it in. That something is an MCP-Apps host. Run this agent programmatically with no host attached and it will block at `ggui_consume`, waiting on a render nobody can see — correct behavior, but not a finished demo. The fastest way to watch the whole loop close is to run the same prompt inside Claude Desktop connected to GGUI ([three-minute setup](/quickstart/claude-desktop/), zero code); this page is the programmatic path that gets your own agent onto the same wire.\n\n```\nYour Agent → mcp.ggui.ai → MCP-Apps render (ui://ggui/render/<id>) → User submits → Agent gets typed data\n```\n\n## Prerequisites\n\n- **Node.js** 20+\n- **A free GGUI console account.** Sign in at [the GGUI console](https://console.ggui.ai) with Google, GitHub, or email. (The GGUI console is the end-user dashboard for hosted GGUI — see the [glossary](/glossary/) if the `ggui` / `guuey` split confuses you.)\n\n## Step 1: Pick an app and mint an SDK API key\n\n1. Sign in at [the GGUI console](https://console.ggui.ai). You land on `/apps` — new accounts come pre-provisioned with one default app; create another with **New App** if you want to scope this quickstart to its own surface (e.g. `feedback-demo`).\n2. Open the app and go to **Keys** (`/apps/[appId]/keys`). Name the key (e.g. `feedback-demo-sdk`) and click **Generate per-app key**.\n3. The key reveals exactly once — **copy it (`ggui_user_…`) immediately.** Lose it and you generate a new one; there's no recovery. You also need the app's ID: it is an opaque 8-character string with no prefix, and the app's overview page shows the full endpoint, `mcp.ggui.ai/apps/<appId>`, ready to copy.\n\n:::caution\nNever commit `ggui_user_*` keys. Use `.env` files and a secret manager in production.\n:::\n\n:::tip[Prefer the CLI?]\n`ggui keys create --name feedback-demo-sdk` does the same thing from your terminal once you've run [`ggui login`](/cli/login/). The minted key still lands as one row in the console's keys table.\n:::\n\n## Step 2: Install the dependencies\n\n```bash\nnpm install @anthropic-ai/claude-agent-sdk @ggui-ai/protocol\n# or: pnpm add @anthropic-ai/claude-agent-sdk @ggui-ai/protocol\n# or: yarn add @anthropic-ai/claude-agent-sdk @ggui-ai/protocol\n```\n\n- **`@anthropic-ai/claude-agent-sdk`** runs Claude as a tool-using agent and speaks MCP natively — no wrapper needed.\n- **`@ggui-ai/protocol`** exports `GGUI_AGENT_SYSTEM_PROMPT`, the canonical system prompt that teaches Claude the handshake → render → consume loop.\n\n:::tip[Other LLMs]\nNot using Claude? Use the raw [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) instead — see [`/examples/generic-mcp/`](/examples/generic-mcp/) for the low-level pattern, and [`/examples/openai-agent/`](/examples/openai-agent/) / [`/examples/gemini-agent/`](/examples/gemini-agent/) for vendor-specific wiring.\n:::\n\n## Step 3: Write your agent\n\nCreate `agent.ts`. The Claude Agent SDK connects to `mcp.ggui.ai` directly via its `mcpServers` config — your code just streams Claude's messages and lets the model drive the GGUI tools.\n\n```typescript\nimport { query } from \"@anthropic-ai/claude-agent-sdk\";\nimport { GGUI_AGENT_SYSTEM_PROMPT } from \"@ggui-ai/protocol\";\n\n// 1. Point Claude's MCP client at your app's hosted endpoint. Bearer-auth\n//    with the `ggui_user_*` key you minted in Step 1. Note: the per-app\n//    cloud endpoint is the bare `/apps/<appId>` path — NO `/mcp` suffix\n//    (that suffix is local-`ggui serve`-only).\nconst mcpServers = {\n  ggui: {\n    type: \"http\" as const,\n    url: \"https://mcp.ggui.ai/apps/<your appId>\",\n    headers: { Authorization: `Bearer ${process.env.GGUI_MCP_BEARER!}` },\n  },\n};\n\n// 2. Allow Claude to call every ggui tool. The `mcp__<server>__<tool>`\n//    naming is the SDK's convention — `<server>` = `ggui` (the key above).\nconst allowedTools = [\n  \"mcp__ggui__ggui_handshake\",\n  \"mcp__ggui__ggui_render\",\n  \"mcp__ggui__ggui_update\",\n  \"mcp__ggui__ggui_amend\",\n  \"mcp__ggui__ggui_emit\",\n  \"mcp__ggui__ggui_consume\",\n  \"mcp__ggui__ggui_get_session\",\n];\n\nasync function main() {\n  const prompt =\n    \"Collect product feedback from the user. Show a feedback form with a \" +\n    \"1-5 star rating and a comments text area, wait for them to submit, \" +\n    \"then summarize what they said.\";\n\n  // 3. Stream the conversation. Claude reads GGUI_AGENT_SYSTEM_PROMPT,\n  //    decides when to call ggui_handshake / ggui_render,\n  //    polls ggui_consume until the user submits, and reports back —\n  //    all without any wrapper SDK on your side.\n  for await (const msg of query({\n    prompt,\n    options: {\n      mcpServers,\n      allowedTools,\n      systemPrompt: GGUI_AGENT_SYSTEM_PROMPT,\n    },\n  })) {\n    if (msg.type === \"assistant\") {\n      for (const block of msg.message.content) {\n        if (block.type === \"text\") console.log(block.text);\n      }\n    } else if (msg.type === \"result\") {\n      console.log(\"Done:\", msg.subtype);\n    }\n  }\n}\n\nmain().catch(console.error);\n```\n\n## Step 4: Run it\n\n```bash\nexport ANTHROPIC_API_KEY=\"sk-ant-...\"   # for the Claude Agent SDK\nexport GGUI_MCP_BEARER=\"ggui_user_...\"     # for mcp.ggui.ai\nnpx tsx agent.ts\n```\n\nYou'll see Claude narrate the handshake, call `ggui_render` (which returns `{ sessionId, resourceUri }` — the render surfaces as an MCP-Apps resource at `ui://ggui/render/<id>`, not a clickable link), and then **block on `ggui_consume`**, polling for the user's submission.\n\nThat block is the expected end of the programmatic path: the render exists, and nothing is mounting it. Two ways to close the loop, easiest first:\n\n- **Run the prompt in an MCP-Apps host** — no code at all. Claude Desktop or claude.ai connected to GGUI mounts the resource inline, you submit, and the agent's `ggui_consume` returns. Setup is three minutes: [Use GGUI in Claude Desktop](/quickstart/claude-desktop/).\n- **Embed it in your own app** — Step 5 below mounts the render with the React host helpers. More wiring, and the right answer once you are putting this in front of your own users.\n\n:::note[How this differs from a wrapper SDK]\nThere is no `GguiClient` import, no `await ggui.handshake(…)`, no `waitForCompletion` helper. The Claude Agent SDK calls the GGUI MCP tools directly; `GGUI_AGENT_SYSTEM_PROMPT` is what teaches the model the protocol's handshake → render → consume sequence. Same wire protocol, zero wrapper code.\n:::\n\n## Step 5 (optional): Embed in React\n\nWant the UI inside your own app? Install the React host helpers and the MCP-Apps host:\n\n```bash\nnpm install @ggui-ai/mcp-apps-react @mcp-ui/client\n```\n\nA GGUI render is an **MCP-Apps resource**. You drive the conversation with the `useMcpAppsChat` hook and mount each render's sandboxed iframe with `<AppRenderer>` (imported directly from `@mcp-ui/client` — GGUI doesn't re-export it):\n\n```tsx\nimport { AppRenderer } from \"@mcp-ui/client\";\nimport { useMcpAppsChat } from \"@ggui-ai/mcp-apps-react/chat-helpers\";\n\nfunction Chat({ agentUrl }: { agentUrl: string }) {\n  const { entries, sessions, send, handleAppMessage } = useMcpAppsChat({\n    chatEndpoint: `${agentUrl}/agent`,\n  });\n\n  // - render `entries` as chat bubbles; call `send(prompt)` to talk to the agent\n  // - mount each `sessions` entry with <AppRenderer> — it needs a sandbox-proxy\n  //   origin + onReadResource / onCallTool relay + onMessage={handleAppMessage}\n}\n```\n\n`useMcpAppsChat` talks to your **agent backend** (the process running the Step-3 `query()` loop, exposed over HTTP — `@ggui-ai/agent-server` gives you a brand-neutral `POST /agent` endpoint for exactly this). `<AppRenderer>`'s sandbox + resource-read + tool-call relay wiring is non-trivial; the complete runnable reference is the [`ggui-basic-web`](https://github.com/ggui-ai/ggui/tree/main/samples/apps/ggui-basic-web) sample. **Start there.**\n\n:::note[The render hosts its own live channel]\nYou don't open a WebSocket in your app to receive render updates. The sandboxed iframe owns its own live channel; your host code just mounts `<AppRenderer>`. Real-time updates (see [Real-Time Dashboard](/cookbook/real-time-dashboard/)) flow inside the iframe.\n:::\n\n## What just happened\n\nUnder the hood, Claude drove these MCP tool calls against `mcp.ggui.ai`:\n\n1. **`ggui_handshake`** negotiated a **contract** from a natural-language intent + draft, returning a `handshakeId` + a server suggestion (cache / agent / synth).\n2. **`ggui_render`** with `{ handshakeId, props }` materialized the contract: GGUI matched a cached **blueprint** (or synthesized a fresh React component), minted a `sessionId`, and returned `{ sessionId, resourceUri }` — the render is an MCP-Apps resource at `ui://ggui/render/<id>`, surfaced on the tool result's `_meta.ui.resourceUri`. (There is no clickable URL on the wire.)\n3. A host mounted that resource — your app via `<AppRenderer>` (Step 5), or an MCP-Apps host like claude.ai inline — and the user submitted the form.\n4. **`ggui_consume`** delivered the user's submit gesture as a `ConsumeEventEntry` (`{ intent, actionData, uiContext, ... }`).\n\nThere is no terminal `close` ceremony — a render simply stays reopenable (see [Rehydration](/concepts/rehydration/)). There is no per-render time limit.\n\nEverything above the wire is `GGUI_AGENT_SYSTEM_PROMPT` + the Claude Agent SDK's tool loop — no ggui-specific client code on your side.\n\n```\nAgent                      mcp.ggui.ai            MCP-Apps host\n  │                          │                  (your app / claude.ai)\n  │── handshake ───────────→ │                           │\n  │← { handshakeId,        ─ │                           │\n  │     suggestion }         │                           │\n  │── render(handshakeId,  ─ │                           │\n  │     props) ────────────→ │                           │\n  │                          │── match/synth blueprint   │\n  │← { sessionId,           ─ │                           │\n  │     resourceUri }        │                           │\n  │                          │── ui://ggui/render/<id> ──→│ (host mounts iframe)\n  │                          │                           │\n  │── consume(sessionId) ───→ │                           │\n  │                          │←── submit gesture ────────│\n  │← { events } ──────────── │                           │\n  │                          │                           │\n  │   (no explicit close — render stays reopenable)      │\n```\n\n## Next steps\n\n- **[Claude agent example](/examples/claude-agent/)** — canonical reference implementation for the snippet above\n- **[MCP protocol reference](/api/mcp-protocol/)** — every `ggui_*` tool, request/response, and error code\n- **[React host helpers](/host-helpers/react/)** — embed GGUI renders directly in your own React app with `useMcpAppsChat` + `<AppRenderer>`\n- **[Other LLMs](/examples/generic-mcp/)** — raw `@modelcontextprotocol/sdk` recipe; also [OpenAI](/examples/openai-agent/), [Gemini](/examples/gemini-agent/)\n- **[Feedback-form cookbook](/cookbook/feedback-form/)** — the recipe above, with variations\n- **[Troubleshooting](/troubleshooting/)** — common issues and fixes\n- **[Glossary](/glossary/)** — gadget vs tool vs blueprint, GGUI vs guuey, and the rest\n- **[Agentic App Builders](/agentic-app-builders/)** — if your goal is to make an existing app agent-drivable rather than building a fresh agent."},{"path":"/quickstart/self-hosted.md","title":"Self-hosted quickstart","description":"Start from the GGUI samples and run an agentic app locally — no account, no cloud, no GGUI API key.","body":"---\ntitle: Self-hosted quickstart\ndescription: Start from the GGUI samples and run an agentic app locally — no account, no cloud, no GGUI API key.\n---\n\nRun the entire protocol yourself — no account, no cloud. This is the same protocol the hosted quickstarts use — nothing about your agent changes between them — see [Portability](/hosted/portability/).\n\nAssemble a complete GGUI agentic app from the canonical samples and run it locally — your agent, the GGUI MCP server, a sample MCP server, and a web client.\n\n```\nagent ── MCP ──→ ggui serve ──→ MCP-Apps resource ui://ggui/render/<id>\n```\n\nTwo ways in — same protocol either way, in the same order as the [repo README](https://github.com/ggui-ai/ggui#quick-start--pick-your-path):\n\n1. **Composed path, on guuey's dev tooling:** published SDKs end-to-end. [guuey](https://guuey.com) is a separate agent-hosting platform; this path uses its dev tools to run the agent, and GGUI renders the UI.\n2. **Bring your own framework:** compose the framework-native samples (Claude Agent SDK / OpenAI Agents SDK / Google ADK) into one workspace, with GGUI alone and no guuey dependency.\n\n## Path 1: composed path, on guuey's dev tooling\n\nOne flow from a `guuey.json` to a rendered, interactive todo UI — every piece a published SDK. [guuey](https://guuey.com)'s dev tooling runs the agent (`@guuey/cli` + `@guuey/worker`), the GGUI runtime is the dev router's injected MCP default, and the web client talks to the router with `@guuey/agent-client`.\n\nPrerequisites: **Node.js 22+**, **pnpm**, and an **`ANTHROPIC_API_KEY`** (one key drives both the agent and GGUI's UI generation).\n\n```bash\ngit clone https://github.com/ggui-ai/ggui && cd ggui\npnpm install                        # workspace deps — `guuey dev` spawns the colocated\n                                    # todo MCP from samples/mcp-servers/todo\nexport ANTHROPIC_API_KEY=sk-ant-…   # in every terminal below\n\n# terminal 1 — the ggui runtime MCP (guuey's dev router injects ggui → this port)\nnpx -y @ggui-ai/cli serve --mcp-only --dev-allow-all   # http://127.0.0.1:6781/mcp\n\n# terminal 2 — the agent half: guuey.json + a Claude agent worker\ncd samples/agents/with-guuey\nnpm install\nnpm run dev                              # guuey dev --serve → http://localhost:6790\n\n# terminal 3 — the web half: chat + rendered ggui cards\ncd samples/apps/with-guuey-web\nnpm install\nnpm run dev                              # http://127.0.0.1:6890\n```\n\n`--dev-allow-all` is what makes terminal 1 usable here: `/mcp` is bearer-gated by default, and both callers in this path arrive anonymous — the dev router's own GGUI connection (it carries no dev token; only colocated servers get one) and the web half's browser relay. Without the flag, the first `ggui_render` returns `401`. It is a loopback convenience — never put it on a public URL.\n\nOpen **`http://127.0.0.1:6890`** and ask for your todos: the agent calls the todo MCP, renders an interactive todo UI through GGUI, and your clicks flow back to the agent. Prefer a scaffolded start? `npx @guuey/create-agentic-app` scaffolds a guuey agentic app of the same shape (agent + MCP + GGUI + web) in one command.\n\n**Before / after.** This is the composed path's headline. The framework-native web client (`samples/apps/ggui-basic-web`) hand-rolls its agent loop in a 702-line `Chat.tsx` — SSE parsing, the per-turn status machine, the transcript fold, resource narrowing, and generative-UI recognition. The composed web half replaces all of that with `useAgentInvoke` from `@guuey/agent-client/react` plus `@guuey/mcp-apps-host`'s view-mount dispatcher (`toolResultViewMount` — inline mcp-ui resources first, GGUI renders second, bare `ui://` locators last); what remains in `samples/apps/with-guuey-web` is rendering — an `App.tsx` that is mostly JSX.\n\nTwo per-path notes, stated where you hit them (full detail in the sample READMEs — [`samples/agents/with-guuey`](https://github.com/ggui-ai/ggui/tree/main/samples/agents/with-guuey) · [`samples/apps/with-guuey-web`](https://github.com/ggui-ai/ggui/tree/main/samples/apps/with-guuey-web)):\n\n- **Dev-server trust:** `guuey dev` runs your agent unjailed with your environment — standard dev-server trust; run it in a container if that posture doesn't fit.\n- **Fresh sessions only:** `guuey dev --serve` keeps sessions in memory and serves no history endpoint — every page load starts a fresh thread. Durable state belongs in your MCP servers (the todo list survives reloads); reload-repaint of chat history is a hosted-platform feature.\n\nThis path drives the GGUI protocol through guuey's published SDKs. The protocol itself has no guuey dependency — path 2 below (and the self-host and hosted paths in the repo README) runs without it, and the framework-native samples stay first-class.\n\n## Path 2: bring your own framework — framework-native samples\n\nCompose the framework-native samples into one workspace — no guuey dependency. Composing is a real step, not a figure of speech; once the tree exists, a single `pnpm dev` runs all four services.\n\n### Prerequisites\n\n- **Node.js** 22+ — the repo declares `engines.node >= 22`.\n- **pnpm** 11+ — required, not a preference. The workspace carries pnpm-only settings (`nodeLinker: hoisted`, `autoInstallPeers`, `allowBuilds`) that npm and bun do not read.\n- An MCP-capable agent runtime (Claude Desktop, Claude Code, Cursor, or anything that reads `.mcp.json`).\n- No account, no GGUI API key. Everything is local (generation runs on your own LLM key — Step 2).\n\n### Step 1: Clone the samples\n\n```bash\ngit clone https://github.com/ggui-ai/ggui && cd ggui\npnpm install\n```\n\nThe clone gives you the pieces, not a preassembled app. Four canonical samples compose into one small monorepo: `samples/agents/<sdk>/` → `servers/agent/` (your agent — `claude-agent-sdk` / `openai-agents-sdk` / `google-adk`), `samples/gguis/default/` → `servers/ggui/` (the GGUI MCP server config), `samples/mcp-servers/todo/` → `servers/mcps/todo/` (a sample MCP server), and `samples/apps/ggui-basic-web/` → `apps/web/` (a web client).\n\n### Step 1.5: Compose them into one app\n\nComposing is a manual step today — the repo root has no `dev` script and no `servers/` directory until you make one. Two references ship in the repo, and they are the exact ones the project's own end-to-end suite uses:\n\n- **`e2e/samples-render/app-shell/`** — the root wrapper: the `package.json` that carries `dev` plus the granular `dev:ggui` / `dev:agent` / `dev:mcps` / `dev:web` / `dev:stop` scripts, the `pnpm-workspace.yaml` describing the composed layout, and the `pnpm dev` orchestrator itself.\n- **`e2e/samples-render/scripts/compose-app.mjs`** — the copy plan, including the `workspace:*` → published-version rewrite each sample's dependencies need once they live outside this repo's workspace.\n\nCopy the wrapper to an empty directory, copy the four samples to the target paths above, and you have the tree the rest of this path describes. See the [samples quickstart in the repo README](https://github.com/ggui-ai/ggui#quick-start--pick-your-path) for the same layout in the repo's own words.\n\n### Step 2: Boot the composed app\n\nSet an LLM key in `.env.local` at the composed app's root (any one — the boot probe walks `anthropic` → `openai` → `google` → `openrouter`):\n\n```bash\n# .env.local\nANTHROPIC_API_KEY=sk-ant-...\n```\n\n```bash\npnpm dev\n```\n\nFrom the composed root, `pnpm dev` boots the whole app together — the GGUI MCP server (`ggui serve` from [`@ggui-ai/cli`](https://www.npmjs.com/package/@ggui-ai/cli), embedding `@ggui-ai/mcp-server`), your agent, the sample MCP server, and the web client. The GGUI server (`servers/ggui`) defaults to `http://127.0.0.1:6781`:\n\n- **MCP endpoint:** `http://127.0.0.1:6781/mcp`\n- **WebSocket (live channel):** `ws://127.0.0.1:6781/ws`\n- **Operator console:** `http://127.0.0.1:6781/`\n\nGranular scripts (`dev:ggui`, `dev:agent`, `dev:mcps`, `dev:web`, `dev:stop`) run the pieces individually — they are declared in the wrapper's `package.json`.\n\n:::tip[Local dev auth]\nThe composed `servers/ggui` start script passes `--dev-allow-all`, so any non-empty bearer (the `\"Bearer dev\"` in Step 3 below) resolves as `builder`. Bare `ggui serve` does **not** — its default is pair-minted bearers, and a `Bearer dev` config gets a `401` against it. `--dev-allow-all` is fine on `127.0.0.1` and **must never reach the open internet**; switch to [pairing](/self-hosted/pairing/) when you tunnel out.\n:::\n\n:::tip[WebSocket URL differs by deployment]\n\n- **Self-hosted / OSS (this guide):** `ws://127.0.0.1:6781/ws` — or `wss://<your-server>/ws` once you put TLS in front.\n- **Hosted ggui:** `wss://mcp.ggui.ai/ws` — handled for you. See [Build an agent on hosted GGUI](/quickstart/hosted-agent/).\n  :::\n\n### Step 3: Point an agent runtime at the local MCP\n\nCreate `.mcp.json` at your project root with:\n\n```json\n{\n  \"mcpServers\": {\n    \"ggui\": {\n      \"url\": \"http://127.0.0.1:6781/mcp\",\n      \"headers\": { \"Authorization\": \"Bearer dev\" }\n    }\n  }\n}\n```\n\nClaude Desktop, Claude Code, Cursor, and any runtime that reads `.mcp.json` will discover the agent-facing tool catalogue — `ggui_handshake` / `ggui_render` / `ggui_update` / `ggui_amend` / `ggui_emit` / `ggui_consume`, plus discovery tools (`ggui_get_session`, `ggui_list_sessions`, `ggui_list_gadgets`, `ggui_list_themes`, blueprint search/render).\n\nThe flow is **`handshake` → `render`**, with `ggui_amend` to repaint props on the mounted card in place and `ggui_update` to mint a new history card when a change is worth its own transcript entry. Write a system prompt that tells the LLM when to render UIs — the [Examples](/examples/claude-agent/) section has working recipes per framework.\n\n## What works today\n\n- ✅ **Local server + wsToken-gated WebSocket subscribe → ack** work end-to-end — the iframe receives `wsUrl`/`wsToken` on the `ai.ggui/render` slice. Render plumbing is production-shaped, not a mock.\n- ✅ **`handshake` → `render`** delivers an MCP-Apps resource (`ui://ggui/render/<id>`) that an MCP-Apps host mounts.\n- ✅ **Component generation is wired via BYOK.** Export `ANTHROPIC_API_KEY` (or `OPENAI_API_KEY` / `GOOGLE_API_KEY` / `OPENROUTER_API_KEY`) before `ggui serve` and `ggui_render` runs the real `@ggui-ai/ui-gen` pipeline (blueprint match → synth). Without any key, `ggui_render` returns a \"Connect Claude\" card pointing at the local `/settings` page rather than failing.\n- ✅ **Per-app theming.** Your `servers/ggui/ggui.json` sets `theme: {preset: 'indigo', mode: 'dark'}`; agents can list presets via `ggui_list_themes` and override per render with `ggui_render({themeId})`.\n- 🔒 **Default auth is pair-minted.** `/mcp` rejects bearers that weren't issued by the pairing flow. Pass `--dev-allow-all` to accept any non-empty bearer as `builder` (safe only on `127.0.0.1`), or swap in a real `AuthAdapter` via `createGguiServer({ auth })` before exposing the server beyond localhost.\n\n## What this is not\n\n- **Not a hosted service.** No GGUI cloud account, no billing, no managed dashboards. For those, see [Build an agent on hosted GGUI](/quickstart/hosted-agent/).\n- **Not a managed billing surface.** Generation runs on YOUR provider key (BYOK) — every `ggui_render` bills the provider directly. The hosted GGUI cloud bundles credit-metered billing and managed keys; this OSS path runs on your own provider key today.\n\n## Vocabulary\n\n- **Tool** — an agent-side action exposed over MCP (`ggui_render`, `ggui_handshake`, …).\n- **Gadget** — a renderer-side capability surfaced inside the viewer (formerly `clientLibraries`).\n- **Blueprint** — a cached component recipe matched before generation runs.\n\nFull definitions: [Glossary](/glossary/).\n\n## Just the bare MCP server\n\nThe composed app orchestrates everything through `pnpm dev`. If you only want the OSS MCP server — no agent or web supervision — run [`ggui serve`](/cli/serve/) against a `ggui.json` directly (the same `ggui serve` the composed samples' `servers/ggui` runs):\n\n```bash\n# = ggui serve --mcp-only --dev-allow-all --port 6781\npnpm --filter ./servers/ggui start\n```\n\nWorking from a monorepo clone rather than the published packages? Build the workspace copies first:\n\n```bash\npnpm --filter @ggui-ai/cli build\nnode packages/ggui-cli/dist/cli.js serve --mcp-only\n```\n\n## What's next\n\n- **[MCP Protocol Reference](/api/mcp-protocol/)** — wire format and tool catalogue (same on OSS and hosted).\n- **[WebSocket Protocol](/api/websocket-protocol/)** — live-channel envelopes (`ActionEnvelope`, `StreamEnvelope`).\n- **[Examples](/examples/claude-agent/)** — MCP-config-only integrations with system-prompt recipes (Claude, OpenAI, Gemini, generic MCP).\n- **[Build an agent on hosted GGUI](/quickstart/hosted-agent/)** — managed generation and dashboards on GGUI cloud.\n- **[GitHub repo](https://github.com/ggui-ai/ggui)** — source, issue tracker, full monorepo walkthrough."},{"path":"/sdk/gadgets.md","title":"Gadgets SDK","description":"Wrap any 3rd-party browser library (Leaflet, Mapbox, Stripe, …) into an LLM-callable React hook with `createGguiGadget` from `@ggui-ai/gadgets`.","body":"---\ntitle: Gadgets SDK\ndescription: Wrap any 3rd-party browser library (Leaflet, Mapbox, Stripe, …) into an LLM-callable React hook with `createGguiGadget` from `@ggui-ai/gadgets`.\n---\n\n`@ggui-ai/gadgets` is the standard library of browser-capability hooks **and** the wrapper SDK that lets you bind any 3rd-party library into the same uniform contract the LLM already knows.\n\n- **STDLIB hooks** — `useGeolocation`, `useCamera`, `useClipboardWrite`, `useClipboardPaste`, `useNotifications`, `useFilePicker`, `useMicrophone`.\n- **Wrapper SDK** — `createGguiGadget` for Leaflet, Mapbox, Stripe, Chart.js, Three.js, or anything else with a browser API.\n\nSee [Gadgets — deterministic islands](/concepts/gadgets/) for why the wrapper-only design exists, [Glossary](/glossary/) for the gadget / tool / blueprint vocabulary, and [Marketplace Registry](/hosted/marketplace/) for distribution.\n\n<!-- prettier-ignore-start -->\n\n:::tip[Wrapper-only — by design]\n3rd-party libraries enter the system ONLY through `createGguiGadget`. The LLM never sees raw library APIs. Three architectural non-negotiables make this work:\n\n1. **Wrapper-only entry** — every 3rd-party library wraps to a stable React hook contract. The LLM learns ONE pattern.\n2. **Operator-controlled registry gating** — only exports the operator has registered on `App.gadgets` may appear in `contract.clientCapabilities.gadgets`. Render validates and rejects with `gadget_not_registered` on any miss.\n3. **Teaching text is part of the spec** — `description` (what), `usage` (when), `example` (concrete shape) are required on every wrapper. Optional `gotchas` (anti-patterns / known misuse). Without them, wrappers are technically valid but practically unusable: the LLM either skips them or misuses them.\n:::\n<!-- prettier-ignore-end -->\n\n## Installation\n\n```bash\nnpm install @ggui-ai/gadgets\n```\n\n## Authoring a wrapper\n\n```ts\nimport { createGguiGadget } from \"@ggui-ai/gadgets\";\n\nexport const useLeafletMap = createGguiGadget<\n  // TOutput — what the hook resolves with on `status: 'completed'`.\n  { containerRef: (el: HTMLDivElement | null) => void },\n  // TOptions — what the calling component passes in.\n  { center: [number, number]; zoom: number }\n>({\n  // Canonical hook name — the export name a contract references\n  // under clientCapabilities.gadgets[<package>][<exportName>]. The\n  // `use`-prefixed camelCase name marks this export as a hook.\n  hook: \"useLeafletMap\",\n\n  // REQUIRED teaching text. Synth + decision LLM both see this with\n  // a 300-char per-entry budget; description is preserved, usage\n  // truncates first when over budget.\n  description:\n    \"Render an interactive Leaflet map with tile layer and pan/zoom controls. Returns a container ref to attach to a <div>.\",\n  usage:\n    \"Mount when the intent names a rendered map (location browsing, route preview, points-of-interest grid). Pass `center: [lat, lng]` + `zoom: 2..20`.\",\n\n  // REQUIRED example. JsonValue — concrete shape the LLM uses to\n  // pattern-match on cold gen.\n  example: {\n    call: \"const map = useLeafletMap({ center: [37.7749, -122.4194], zoom: 12 });\",\n    returns: {\n      status: \"completed\",\n      value: { containerRef: \"<DOM ref callback>\" },\n    },\n  },\n\n  // Optional anti-patterns / library quirks. Surfaces in the\n  // boilerplate generator's prompt; the LLM uses them to avoid known\n  // misuse patterns.\n  gotchas:\n    \"Leaflet requires the container <div> to have a non-zero height before the map mounts — apply `style={{ height: 400 }}` (or similar) directly. Default-marker icons require leaflet.css to be in the document; the styleUrl on this descriptor covers that.\",\n\n  // REQUIRED. `version` is part of the blueprint cache key, so a\n  // wrapper bump automatically invalidates stale cached generations.\n  version: \"0.0.1\",\n\n  // `package` AND `version` are both REQUIRED — bare npm name, exact\n  // semver pin (no URLs, no ranges). `bundleUrl` is optional; when\n  // set it wins for boilerplate import emission.\n  //\n  // Resolution order (operator wins over author wins over spec\n  // default): explicit `bundleUrl` → operator `bundleHost` override\n  // → author `bundleHost` → spec default `registry.ggui.ai`. With\n  // `bundleHost` + `package` + `version` the server assembles\n  // `https://<bundleHost>/bundles/<scope>/<name>/<version>/bundle.js`\n  // — prefer this over hardcoded `bundleUrl`.\n  package: \"@my-org/ggui-leaflet\",\n  bundleHost: \"registry.ggui.ai\",\n\n  // Optional companion CSS bundle. The wrapper SHOULD self-inject\n  // any required CSS in its hook; the styleUrl is purely an origin\n  // declaration so the renderer's CSP allows the load. Same\n  // `bundleHost`-based resolution applies — explicit `styleUrl`\n  // overrides the host-derived `style.css` path.\n\n  // API-call origins (XHR / fetch / WebSocket / `<img src>`). The\n  // renderer's Content-Security-Policy unions these into BOTH\n  // connect-src AND img-src so map tiles loaded via <img> work.\n  connect: [\"https://tile.openstreetmap.org\"],\n\n  // Public env keys this wrapper consumes (see \"Public env channel\"\n  // below). Both wire + registry zod schemas enforce the same\n  // `GGUI_PUBLIC_APP_[A-Z0-9_]+` prefix as App.publicEnv. Leaflet\n  // doesn't need a token; Mapbox does.\n  // requires: [\"GGUI_PUBLIC_APP_MAPBOX_TOKEN\"],\n\n  // The React hook. Wrappers bundle their underlying library at\n  // build time (esbuild / tsup) and `import` it inside the hook.\n  // The function MUST return the standard `{value, status, start, …}`\n  // shape that satisfies `GadgetHook<TOutput, TOptions>`.\n  hookImpl: (props) => {\n    // … wrap Leaflet's lifecycle into ggui's stable hook contract …\n    return {\n      value: { containerRef: () => undefined },\n      status: \"completed\",\n      start: async () => undefined,\n    };\n  },\n});\n```\n\nThe factory **synchronously zod-validates** the spec at module load. Missing `description` / `usage` / `example`, or a missing `package` / `version`, throws `WrapperConformanceError` with the field path. Authors see the error at import time — not at runtime.\n\n`useLeafletMap` is a callable hook; the immutable wrapper descriptor is attached as `useLeafletMap.descriptor`. Operators register the descriptor on `App.gadgets`.\n\nFor distribution, wrappers ship as a `ggui.gadget.json` manifest (see [Marketplace Registry](/hosted/marketplace/)). The registry's publish endpoint computes the bundle's `sha384` and stamps it on the registered entry's `bundleSri`; `ggui gadget install` writes the descriptor (including `bundleSri` + resolved `bundleUrl`) into `ggui.json#app.gadgets[]`. Hand-authored entries omit `bundleSri` — the iframe runtime falls back to integrity-less dynamic `import()`.\n\nNon-stdlib registered descriptors also carry `typesUrl` (HTTPS URL to the wrapper's `.d.ts`) and `typesSri` (SHA-384 SRI over those types), so the generator can type-check against the wrapper's real surface. `typesUrl` is REQUIRED at registration time for any non-stdlib package; `typesSri` is stamped by the registry on publish (optional for hand-authored entries).\n\n## Multi-export and component packages\n\n`createGguiGadget` is the single-hook convenience — it authors exactly one hook export. For a package that ships more than one export, or a **component** export (like the `LeafletMap` component referenced later on this page), author it with `defineGadgetPackage` instead — the general builder for hook and/or component gadget packages, also from `@ggui-ai/gadgets`:\n\n```ts\nimport { defineGadgetPackage } from \"@ggui-ai/gadgets\";\nimport type { ComponentType } from \"react\";\n\n// Export each impl directly — the builder never wraps or re-types it.\nexport const LeafletMap: ComponentType<{ center: [number, number]; zoom: number }> = (props) => {\n  // … render the map …\n};\nexport const useLeafletMap = (options: { center: [number, number]; zoom: number }) => {\n  // … the hook version, same lifecycle as the wrapper example above …\n};\n\nexport const leafletDescriptor = defineGadgetPackage({\n  package: \"@my-org/ggui-leaflet\",\n  version: \"0.0.1\",\n  bundleHost: \"registry.ggui.ai\",\n  connect: [\"https://tile.openstreetmap.org\"],\n  exports: [\n    {\n      // PascalCase `component` key ⇒ a component export.\n      component: \"LeafletMap\",\n      impl: LeafletMap,\n      description: \"Render an interactive Leaflet map with tile layer and pan/zoom controls.\",\n      usage: \"Mount when the intent names a rendered map (location browsing, route preview).\",\n      example: { call: \"<LeafletMap center={[37.7749, -122.4194]} zoom={12} />\", returns: null },\n    },\n    {\n      // `use`-prefixed `hook` key ⇒ a hook export, same shape createGguiGadget produces.\n      hook: \"useLeafletMap\",\n      impl: useLeafletMap,\n      description:\n        \"Render an interactive Leaflet map. Returns a container ref to attach to a <div>.\",\n      usage: \"Mount when the intent names a rendered map (location browsing, route preview).\",\n      example: {\n        call: \"const map = useLeafletMap({ center: [37.7749, -122.4194], zoom: 12 });\",\n        returns: { status: \"completed\", value: { containerRef: \"<DOM ref callback>\" } },\n      },\n    },\n  ],\n});\n```\n\nBoth exports share one package identity and one set of transport metadata (`bundleHost`, `connect`), declared once. `defineGadgetPackage` synchronously zod-validates each export's teaching text and name grammar the same way `createGguiGadget` does, throwing `WrapperConformanceError` at module load on a malformed spec.\n\n## Operator registration\n\nAdd the descriptor (or its JSON shape) to `ggui.json#app.gadgets`:\n\nA `GadgetDescriptor` is a PACKAGE — `package` + `version` + transport\nmetadata declared once, plus an `exports[]` array carrying each\nexport's teaching text:\n\n```json\n{\n  \"app\": {\n    \"slug\": \"leaflet-demo\",\n    \"name\": \"Leaflet gadget demo\",\n    \"gadgets\": [\n      {\n        \"package\": \"@my-org/ggui-leaflet\",\n        \"version\": \"0.0.1\",\n        \"bundleHost\": \"registry.ggui.ai\",\n        \"connect\": [\"https://tile.openstreetmap.org\"],\n        \"exports\": [\n          {\n            \"hook\": \"useLeafletMap\",\n            \"description\": \"Render an interactive Leaflet map…\",\n            \"usage\": \"Mount when the intent names a rendered map…\",\n            \"example\": {\n              \"call\": \"const map = useLeafletMap({ center: [37.7749, -122.4194], zoom: 12 });\",\n              \"returns\": {\n                \"status\": \"completed\",\n                \"value\": { \"containerRef\": \"<DOM ref callback>\" }\n              }\n            },\n            \"gotchas\": \"Leaflet requires the container <div>…\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nA package that ships more than one export (two hooks, or a hook plus a\nReact `component`) adds further entries to the same `exports` array —\nthe transport metadata is declared once and shared.\n\nThe CLI threads `app.gadgets` into the in-process `AppMetadataStore` so the same singleton powers `ggui_list_gadgets`, handshake-time prompt injection, render-time validation + enrichment, and CSP derivation.\n\n### From the console\n\nEditing `ggui.json` by hand isn't the only install path for a hosted app. The [GGUI console](https://console.ggui.ai)'s per-app **Gadgets** tab (`/apps/[appId]/gadgets`) shows exactly what agents receive from `ggui_list_gadgets` — the same three-tier catalog, resolved with the same precedence the server uses at render time: stdlib floor < web-installed < `ggui.json`-declared.\n\n- **stdlib** rows are the seven always-on hooks — action-free.\n- **installed** rows were added through the console's own search + install flow (not `ggui.json`). Each row carries a version menu — picking a version re-installs at that pin — and a two-step-confirm Remove that uninstalls it.\n- **declared** rows came from `ggui.json#app.gadgets`. The console never edits `ggui.json` for you; a declared package always wins over an installed one on an exact `package` collision, so a declared row is display-only here.\n\nUse the console tab for hosted apps where you'd rather browse + click-install a registry package than hand-edit JSON; use `ggui.json#app.gadgets` (above) when you're checking gadget config into source control or self-hosting.\n\n## Contract gadget refs vs `GadgetDescriptor`\n\nTwo distinct shapes — do not confuse them. One is the wire map a host\nagent authors on a contract; the other is the registry-side package\ndescriptor an operator registers. Both are organized around a gadget\n**package** that ships one or more **exports**, where each export is\neither a hook (a `use`-prefixed camelCase name) or a component (a\nPascalCase name):\n\n- **Contract gadget refs** — `clientCapabilities.gadgets` on a `DataContract` is a **package-keyed two-level map** carrying IDENTITY ONLY. The outer key is the npm package name; the inner key is the export name; the inner value is a `GadgetExportUse` — `{ description?, usage? }`, usually just `{}` (optional intent-specific override prose). There is NO `hook` / `component` field on the wire — the export-name GRAMMAR is the discriminator: `useGeolocation` is a hook, `LeafletMap` is a component. There is NO `version`, NO `permission`, NO transport metadata, and NO `binding` key. The wire carries `(package, exportName)` and nothing else.\n- **`GadgetDescriptor`** — the full registry-side PACKAGE descriptor (what operators register on `App.gadgets`). Package-level identity (`package` + `version`) plus transport metadata (bundle URLs, `connect`, `typesUrl` / `typesSri`) declared once, plus an `exports: GadgetExport[]` array (≥1). Each `GadgetExport` is a field-presence-discriminated union — a hook export `{ hook, … }` or a component export `{ component, … }` — carrying that export's teaching text + per-export `permission`. `version`, transport metadata, `permission`, and `requires` ALL resolve server-side from this descriptor at render time; the contract author never restates them. `createGguiGadget` authors a single-export package; multi-export packages add further `exports` entries.\n\n## Agent reference shape\n\nOnce a package is registered on `App.gadgets`, contracts reference its\nexports through the package-keyed `clientCapabilities.gadgets` map —\nouter key = package name, inner key = export name:\n\n```ts\nconst contract = {\n  // … propsSpec / actionSpec / contextSpec …\n  clientCapabilities: {\n    gadgets: {\n      \"@my-org/ggui-leaflet\": {\n        // PascalCase key ⇒ a component export. Empty value is the\n        // common case; an optional `{ description?, usage? }` override\n        // sharpens the teaching text for this specific intent.\n        LeafletMap: {},\n      },\n      \"@ggui-ai/gadgets\": {\n        // `use`-prefixed camelCase key ⇒ a hook export.\n        useGeolocation: {},\n      },\n    },\n  },\n};\n```\n\nThe wire is identity-only: render resolves each `(package, exportName)`\npair against the registered `GadgetDescriptor` and enriches the\npersisted `ComponentGguiSession` with the canonical `version`, transport metadata,\nteaching text, and `permission`. To walk the map in code, call\n`listContractGadgets(contract)` — it flattens the package-keyed map\ninto a `readonly GadgetUse[]`, each entry `{ package, name, description?, usage? }`.\n\nReferences to unregistered exports reject at render validation with `gadget_not_registered` and a did-you-mean hint when a close stdlib match exists (Levenshtein < 3 cutoff). Render also rejects with `gadget_package_mismatch` when a referenced export name belongs to a different registered package than the one keyed.\n\n## What the renderer derives\n\nWhen ANY gadget in `clientCapabilities.gadgets` declares `bundleUrl` / `styleUrl` / `connect[]`, the render's spec-canonical `_meta.ui.csp` block carries the derived `{ connectDomains, resourceDomains }` (per the MCP Apps spec). The MCP-Apps host applies them to the sandboxed iframe as a Content-Security-Policy — conceptually:\n\n```\nscript-src 'self' 'unsafe-inline' <bundleUrl-origins>;\nstyle-src  'self' 'unsafe-inline' <styleUrl-origins>;\nconnect-src 'self' <connect-origins>;\nimg-src    'self' data: <connect-origins>\n```\n\n- `'unsafe-inline'` on `script-src` is needed because the render shell embeds the bootstrap as an inline `<script>` tag.\n- `img-src` derives from `connect[]` so map tiles loaded via `<img>` work without redeclaring origins.\n\nThe `_meta.ui.csp` block is populated ONLY when gadgets declare external origins. Pre-gadget scenarios stay CSP-clean.\n\n## STDLIB hooks (no operator config required)\n\nSeven first-party browser-capability hooks are always available — `useGeolocation`, `useCamera`, `useClipboardWrite`, `useClipboardPaste`, `useNotifications`, `useFilePicker`, `useMicrophone`. The stdlib package (`STDLIB_GADGETS` from `@ggui-ai/protocol`) is a structural floor, not a fallback: declaring your own `App.gadgets` layers on top, and stdlib hooks stay referenceable in any contract (a declared package only overrides stdlib on an exact `package` collision).\n\n## Public env channel — wrappers that need a token\n\nWrappers like Mapbox can't render without an operator-provided token. The public env channel is the typed seam for that: wrappers declare which public keys they consume, operators stamp values on the App, and a single accessor reads them at hook-mount.\n\n**The four moving parts:**\n\n1. **Wrapper declares** what it needs on `requires`:\n\n```ts\nexport const useMapbox = createGguiGadget({\n  hook: \"useMapbox\",\n  // …description / usage / example / package / bundleUrl…\n  requires: [\"GGUI_PUBLIC_APP_MAPBOX_TOKEN\"],\n  hookImpl: () => {\n    // Lazy read inside the hook body — see the rule below.\n    const token = getPublicEnv(\"GGUI_PUBLIC_APP_MAPBOX_TOKEN\");\n    mapboxgl.accessToken = token;\n    // …mount Mapbox…\n  },\n});\n```\n\n2. **Operator stamps** the value on `ggui.json#app.publicEnv`:\n\n```json\n{\n  \"app\": {\n    \"slug\": \"mapbox-demo\",\n    \"publicEnv\": {\n      \"GGUI_PUBLIC_APP_MAPBOX_TOKEN\": \"pk.eyJ...\"\n    },\n    \"gadgets\": [/* …useMapbox descriptor… */]\n  }\n}\n```\n\nKeys MUST match `^GGUI_PUBLIC_APP_[A-Z0-9_]+$` — the prefix is a security boundary, not a convention. **Public means visible to anyone with iframe-source access** (the server inlines projected values verbatim in the bootstrap script tag). Sensitive credentials belong on the agent-side tools surface, NOT here. The `GGUI_PUBLIC_USER_` namespace is reserved for a future per-user channel.\n\n3. **Render gate enforces** at commit time. `assertPublicEnvSatisfied` runs alongside the registry gate; if any declared wrapper's `requires` aren't satisfied by `App.publicEnv`, render rejects with `gadget_public_env_missing` — the error names the missing key + the wrapper that required it, so the operator fix is unambiguous.\n\n4. **Wrapper reads** via `getPublicEnv` from `@ggui-ai/gadgets`:\n\n```ts\nimport { getPublicEnv } from \"@ggui-ai/gadgets\";\n\n// Throws with the available-keys diagnostic if the key is missing\n// AND opts.optional is not set. Use the throwing default for keys\n// you declared on `requires` (render gate guarantees presence) and\n// {optional: true} only when the wrapper has a sensible fallback.\nconst token = getPublicEnv(\"GGUI_PUBLIC_APP_MAPBOX_TOKEN\");\nconst base = getPublicEnv(\"GGUI_PUBLIC_APP_API_BASE\", { optional: true });\n```\n\n**The server projects only what wrappers need.** `derivePublicEnvProjection` computes the union of every declared wrapper's `requires` for the render, then filters `App.publicEnv` to that subset before stamping `_meta[\"ai.ggui/render\"].publicEnv`. Operator-stamped keys that no wrapper consumes stay server-side. Minimum-disclosure by construction.\n\n:::caution[Lazy-call rule]\n\n`getPublicEnv` MUST be called LAZILY inside the hook body (or any code path triggered after first render). **Never at module top level.**\n\n```ts\n// ❌ Throws at wrapper-bundle eval — `globalThis.__ggui__` is not yet installed.\nconst TOKEN = getPublicEnv(\"GGUI_PUBLIC_APP_MAPBOX_TOKEN\");\n\n// ✅ Called per-render after the iframe runtime booted.\nhookImpl: () => {\n  const token = getPublicEnv(\"GGUI_PUBLIC_APP_MAPBOX_TOKEN\");\n  // …\n};\n```\n\nThe iframe runtime loads wrapper bundles BEFORE `installGlobalRegistry`. The hook body, `useEffect`, and the consumer's `start()` callback all run after globals are ready; module-top code does not.\n\n:::\n\n## Reference gadgets\n\nReference samples ship in the OSS repo under `samples/gadgets/` and `samples/gguis/` — start with the Leaflet pair (no token), then Mapbox (public-env channel end-to-end):\n\n- `@ggui-samples/gadget-leaflet` + `@ggui-samples/ggui-leaflet-demo` — Leaflet wrapper, no token required. The recommended \"first wrapper\" example.\n- `@ggui-samples/gadget-mapbox` + `@ggui-samples/ggui-mapbox-demo` — Mapbox wrapper with the public env channel wired end-to-end. The committed demo `ggui.json` uses a `<set-me-before-running>` placeholder for the token (the render gate accepts it; the Mapbox SDK rejects it on first tile request — replace before running).\n\n## Distribution\n\nPublish wrappers to the marketplace registry at `registry.ggui.ai` for public discovery, or self-host your own registry for private gadgets. See [Marketplace Registry](/hosted/marketplace/) and [Self-Hosted Registry](/sdk/self-hosted-registry/).\n\n## Roadmap\n\n- **Per-user public env (`GGUI_PUBLIC_USER_*`).** Reserved namespace — the wire regex rejects it today. A follow-up wires per-user auth-overlay defaults + explicit per-render `publicEnv` input once the per-user shape is locked."},{"path":"/sdk/self-hosted-registry.md","title":"Self-Hosted Registry","description":"Run your own GGUI gadget/blueprint registry with @ggui-ai/registry-server — filesystem-backed, bearer-token auth, byte-compatible with registry.ggui.ai.","body":"---\ntitle: Self-Hosted Registry\ndescription: Run your own GGUI gadget/blueprint registry with @ggui-ai/registry-server — filesystem-backed, bearer-token auth, byte-compatible with registry.ggui.ai.\n---\n\n`@ggui-ai/registry-server` is an OSS registry you self-host. It's byte-compatible with the hosted cloud: gadgets and blueprints published to your registry install the same way they do from `registry.ggui.ai`. Same wire format, same signature verification, same SRI integrity, same CLI verbs (`ggui gadget` / `ggui blueprint`).\n\n## Why self-host\n\n| Use case                   | Why self-host                                                                   |\n| -------------------------- | ------------------------------------------------------------------------------- |\n| Enterprise private gadgets | Air-gapped network; corporate signing keys; data residency mandates             |\n| Local development          | No network round-trip; no auth setup; full lifecycle in CI                      |\n| Integration testing        | Predictable per-test registry state; teardown via `rm -rf <storage-dir>`        |\n| Custom transport           | Implement `RegistryStorage` + `BundleStorage` against any database / blob store |\n\nThe OSS server shares **all** business logic with the cloud Lambda handlers via [`@ggui-ai/registry-core`](https://www.npmjs.com/package/@ggui-ai/registry-core). The transport layer is the only delta — Hono routes for OSS, API Gateway + Lambda for cloud — and every conformance gate, signature check, and row schema is identical.\n\n## Quick start\n\n```bash\n# 1. Pick a storage root + an auth token (rotate-able)\nexport GGUI_REGISTRY_TOKEN=$(openssl rand -hex 32)\nmkdir -p ./registry-data\n\n# 2. Run (npx fetches @ggui-ai/registry-server on first use)\nnpx @ggui-ai/registry-server \\\n  --storage=fs:./registry-data \\\n  --port=9001 \\\n  --bundle-host=http://localhost:9001 \\\n  --registry-hostname=localhost:9001\n```\n\n> **Flag syntax.** Each flag takes its value via `=` (e.g. `--storage=fs:./registry-data`). Space-separated forms (`--storage fs:./registry-data`) are rejected by the parser.\n\nThe server logs one line: `registry-server listening on http://0.0.0.0:9001`. Bundles, signatures, and manifests live under `./registry-data/bundles/`; row data lives under `./registry-data/state/`.\n\n## Publishing to a self-hosted registry\n\nEvery CLI verb resolves the target registry via the same precedence chain: `--registry <url>` flag, then the `GGUI_REGISTRY` env var, then `ggui.json#registry` (walking up from CWD). Read verbs (install/search) additionally fall back to `https://registry.ggui.ai` when nothing is configured, while write verbs (publish, keys register) error instead — so set `GGUI_REGISTRY` (or `ggui.json#registry`) to keep every verb on your self-hosted instance. Publish with the bearer flag:\n\n```bash\nggui gadget publish \\\n  --registry http://localhost:9001 \\\n  --auth bearer \\\n  --token \"$GGUI_REGISTRY_TOKEN\"\n```\n\nFor day-to-day usage, set the env once:\n\n```bash\nexport GGUI_REGISTRY=http://localhost:9001\nexport GGUI_REGISTRY_TOKEN=<value>\n\nggui gadget publish --auth bearer    # token picked up from env\nggui blueprint install @my-org/login-form@0.1.0    # installs are unauthenticated today\n```\n\nThe bearer flag is opt-in. Without it, the CLI sends the access token from your stored `ggui login` session — the default for `registry.ggui.ai`, which verifies it at the gateway (see [`Marketplace § Auth`](/hosted/marketplace/#auth)). Self-hosted registries always use the bearer flag.\n\n## Storage modes\n\n| Mode                  | What it is                                             | When to use                             |\n| --------------------- | ------------------------------------------------------ | --------------------------------------- |\n| `--storage=memory`    | In-process Maps; lost on restart                       | Local dev; CI; per-test isolation       |\n| `--storage=fs:<path>` | JSON rows + filesystem blobs under `<path>`            | Single-node persistent deployments      |\n| Custom (third-party)  | Implement `RegistryStorage` + `BundleStorage` directly | Multi-node, S3, DDB, Postgres, anything |\n\nThe filesystem mode is the recommended single-node default. It uses exclusive-create (`fs.writeFile` with `{ flag: 'wx' }`) to back the atomic `putArtifactVersionIfAbsent` primitive — the load-bearing concurrency guarantee that re-publishes return `409 version_exists` deterministically, even under concurrent writers.\n\nThe in-memory mode is wired via `inMemoryRegistryStorage()` + `inMemoryBundleStorage({ bundleHost })` from `@ggui-ai/registry-core`; filesystem mode via `createFilesystemRegistryStorage({ root })` + `createFilesystemBundleStorage({ root, bundleHost })` from `@ggui-ai/registry-server`. Both pairs implement the same `RegistryStorage` + `BundleStorage` contracts that custom backends must satisfy.\n\n## Opt-in publish-time runtime probe\n\n`createRegistryServer` and `createRegistryApp` accept an optional `blueprintProbe: BlueprintProbeRunner`. When wired, every blueprint publish runs the static conformance gates **and** compiles + renders the blueprint's default export against the manifest's `fixtureProps` in a sandboxed Node `vm` + react-dom server-renderer. Failures return `400` with `code: 'blueprint_runtime_probe_failed'`. The reference implementation is `@ggui-ai/blueprint-probe`'s `blueprintProbeRunner` export.\n\n```ts\nimport { createRegistryServer } from \"@ggui-ai/registry-server\";\nimport { blueprintProbeRunner } from \"@ggui-ai/blueprint-probe\";\n\ncreateRegistryServer({\n  // …storage, bundleStorage, authn, registryHostname, bundleHost as before\n  blueprintProbe: blueprintProbeRunner,\n});\n```\n\nThe `registry-server` CLI does NOT wire a probe by default — leaving it unset means only the static gates from `checkConformance` run. Opt in deliberately:\n\n- `vm.runInContext` is **not** a security boundary. An internal security audit confirmed sandbox escape via `require('react').useState.constructor`. Wire the probe only where the trust boundary is the caller's own process — CI checks, e2e fixtures, or a registry that publishes are gated behind authenticated, trusted operators. Do not enable it on unauthenticated public publish endpoints.\n- The probe also pulls in `happy-dom` + `react-dom/server` — non-trivial deps the lean default server intentionally avoids.\n\nIf you need the probe in production, embed `createRegistryApp` programmatically (so you control the deps + can layer additional pre-checks), rather than running `registry-server` directly.\n\n## Building your own transport\n\nIf you're not running Hono or Node, target [`@ggui-ai/registry-core`](https://www.npmjs.com/package/@ggui-ai/registry-core) directly. The two seams are storage and authn:\n\n```ts\nimport {\n  publishArtifact,\n  readArtifact,\n  searchArtifacts,\n  checkConformance,\n  type RegistryStorage,\n  type BundleStorage,\n  type AuthnContext,\n} from \"@ggui-ai/registry-core\";\n\n// 1. Implement the two storage interfaces against your backend\nclass MyRegistryStorage implements RegistryStorage {\n  /* DDB / Postgres / etc. */\n}\nclass MyBundleStorage implements BundleStorage {\n  /* S3 / GCS / Azure Blob */\n}\n\n// 2. Verify the caller's credentials in your transport, then build an AuthnContext\nfunction verifyCaller(req): AuthnContext | undefined {\n  /* JWT / mTLS / signed header */\n}\n\n// 3. Plug in to your HTTP framework\napp.post(\"/publish\", async (req, res) => {\n  const authn = verifyCaller(req);\n  if (!authn) return res.status(401).send({ error: \"unauthorized\", message: \"no creds\" });\n\n  const result = await publishArtifact(req.body, {\n    storage: myStorage,\n    bundleStorage: myBundleStorage,\n    authn,\n    clock: () => new Date(),\n    registryHostname: req.headers.host,\n  });\n\n  res.status(result.status).send(result.body);\n});\n```\n\nThe `registryStorageContract` + `bundleStorageContract` test helpers under `@ggui-ai/registry-core/testing` let you verify drift-free parity with the reference impls:\n\n```ts\nimport { registryStorageContract, bundleStorageContract } from \"@ggui-ai/registry-core/testing\";\n\ndescribe(\"my custom storage\", () => {\n  registryStorageContract(() => new MyRegistryStorage());\n  bundleStorageContract(() => new MyBundleStorage());\n});\n```\n\n## Non-goals\n\nThe OSS server is intentionally minimal. NOT in scope:\n\n- **TLS termination.** Run behind nginx / Caddy / a load balancer. The server speaks plain HTTP — operators decide their TLS posture.\n- **Rate limiting.** Use a reverse proxy (nginx `limit_req`, Caddy `rate_limit`) or a CDN.\n- **Backup automation.** `cp -r ./registry-data /backup/` is the model. The state is plain JSON + blobs.\n- **Multi-tenant org enforcement.** `visibility: \"private\"` is a per-row flag; the OSS server treats it as a label that requires _any_ valid bearer token. Real org-scoped access control is a deployment-layer concern. (What IS enforced — by the shared core, at publish: the visibility ↔ signing-algorithm pairing. `public` requires a sigstore keyless signature so the publish lands in a public transparency log; `private` requires an Ed25519 author key. Mismatches are rejected with `400 visibility_algorithm_mismatch`.)\n- **Public web UI.** The OSS server exposes only the HTTP API. The hosted cloud's browser lives at [hub.ggui.ai](https://hub.ggui.ai) — a monorepo-only Next.js app (not published, not in this OSS repo) talking to the same public `/search` + `/pkg/*` routes. Self-hosters wanting the same browsing experience build their own client against those two routes.\n- **Cross-registry mirror.** Single-registry only. The hosted cloud and a self-hosted registry are independent stores.\n\n## Reference\n\n- **Marketplace overview** — [`/hosted/marketplace`](/hosted/marketplace/)\n- **Gadgets SDK** — [`/sdk/gadgets`](/sdk/gadgets/)\n- **CLI** — [`/cli`](/cli/)\n- **Protocol spec** — [Protocol overview](/protocol/overview/)\n- **Source** — [`packages/registry-server`](https://github.com/ggui-ai/ggui/tree/main/packages/registry-server) (transport) + [`packages/registry-core`](https://github.com/ggui-ai/ggui/tree/main/packages/registry-core) (shared ops)"},{"path":"/self-hosted/conformance-ci.md","title":"Running the conformance kit in CI","description":"Wire @ggui-ai/protocol-conformance into a third-party CI pipeline — install, invoke, and read the result.","body":"---\ntitle: Running the conformance kit in CI\ndescription: Wire @ggui-ai/protocol-conformance into a third-party CI pipeline — install, invoke, and read the result.\n---\n\n> Thin recipe for implementers: how to run `@ggui-ai/protocol-conformance` in your own CI so a protocol regression on either side shows up as a red build. What conformance _means_ is on the [Conformance](/protocol/conformance/) page; this page is only the wiring.\n\n## Install\n\nThe kit is a plain npm package with no monorepo assumptions — `npm install` resolves the kit plus `@ggui-ai/protocol` and `ws`, and it runs standalone. The receipt behind that is the reference server's own in-process conformance suite ([`conformance.test.ts`](https://github.com/ggui-ai/ggui/blob/main/packages/protocol-reference-server/src/conformance.test.ts), exact-set assertions), which runs the kit with a `host`, a `refusalRegistry` and the server's own refusal projector at **19 pass / 0 fail / 11 skip** on 0.15.0 — the 11 skips are the three browser-host claims, the two transport-refusal rows the suite hands no endpoint projector for, and the six `domain-error` rows: the reference server serves `resources/read` and the live channel and has no `tools/call` plane, so it binds no driver, and that catalog is graded on the first-party server instead ([`domain-error.conformance.test.ts`](https://github.com/ggui-ai/ggui/blob/main/packages/mcp-server/src/domain-error.conformance.test.ts), 6/6). A hostless, flagless CLI run against the same server is 0 pass / 0 fail / 30 skip and exits `2`, by design (see [Invoke](#invoke)).\n\n```bash\nnpm install -D @ggui-ai/protocol-conformance\n```\n\n## Invoke\n\n**Programmatic runner (the real pass/fail signal).** Boot your implementation, hand the runner a [`ConformanceHost`](https://github.com/ggui-ai/ggui/tree/main/packages/protocol-conformance) so setup directives (session provisioning, fault injection) can run, and apply the kit's red-build guard:\n\n```js\nimport { runConformance } from \"@ggui-ai/protocol-conformance\";\n\nconst result = await runConformance({\n  serverUrl: \"http://127.0.0.1:3100\", // bare origin — the runner derives ws://…/ws\n  auth: { kind: \"bearer\", token: process.env.TOKEN },\n  host: myConformanceHost, // dispatchSetup / dispatchTeardown / readSessionField\n});\n\n// A failed fixture is a red build — and so is a run that executed\n// ZERO fixtures (all skips prove nothing). A warned fixture executed,\n// and a warning never fails the run.\nif (result.failed.length > 0 || result.passed.length + result.warned.length === 0) process.exit(1);\n```\n\n**CLI.** The bundled CLI has no `host` wiring, and every wire fixture in the current catalog declares setup steps — so a hostless run with no catalog flag skips them all and exits `2` **by design**. Use the bare form as a connectivity/handshake smoke check, not as your conformance gate:\n\n```bash\nnpx ggui-protocol-conformance --url ws://127.0.0.1:3100/ws --auth bearer:$TOKEN\n```\n\nFour catalogs cannot be reached by a URL alone: `refusal-envelope`, `registry-completeness` and `transport-refusal` grade an in-process function and a data table, and `domain-error` — the kit's first `tools/call` driver — grades what a `tools/call` returns for a Plane-2 failure. The CLI hands them their inputs by flag: `--registry <file.json>` (the deployment's refusal-code registry, a JSON object keyed by code; grades `registry-completeness`), `--projector <module>` (an ES module whose `project(refusal)` — a named or default export — returns the SPEC §7.1 tool result for a refusal, or `null` when the code has no envelope on the render-gate surface; grades `refusal-envelope`), and `--transport-projector <module>` (the same shape, returning the endpoint's `{ httpStatus, error }` or `null` when the code has no transport envelope; grades `transport-refusal`), and `--tool-call-driver <module>` (an ES module exporting `drive(scenario)` — or a default export — that performs one `tools/call` and returns the raw result, or `null` when the tool is not bound; may be async; grades `domain-error`). File and module paths resolve against the working directory. A flag left out leaves its catalog on the scorecard as SKIPPED with the flag named — an ungraded obligation stays visible rather than vanishing. `--only <catalog>/<case>` filters catalog rows the way it filters wire fixtures.\n\n```bash\nnpx ggui-protocol-conformance --url ws://127.0.0.1:3100/ws --auth bearer:$TOKEN \\\n  --registry ./refusal-registry.json --projector ./project.mjs --transport-projector ./endpoint.mjs \\\n  --tool-call-driver ./tool-call-driver.mjs\n```\n\nAgainst the reference server with the kit's sample inputs ([`cli-samples/`](https://github.com/ggui-ai/ggui/tree/main/packages/protocol-conformance/src/cli-samples)), that run is 18 pass / 0 fail / 12 skip and exits `0` — every catalog row graded, every wire fixture SKIPPED for want of a `host`. Without `--tool-call-driver` it is 12 / 0 / 18: the six `domain-error` rows skip with the flag named. The sample driver is canned — it answers the six scenarios the way a conformant server does without calling the server — so those six passes prove the flag's plumbing, not the reference server's wire, which has no `tools/call` plane; the live receipt for the wire is the first-party server's suite linked above.\n\nA projector must be synchronous. One that throws, returns `undefined`, or returns a non-projection is graded as a FAIL on that case (exit `1`) and the remaining cases are still graded — never a crash. Catalog rows count as executed fixtures: a hostless run given a catalog flag can exit `0` while every wire fixture is SKIPPED, so read the scorecard, not the exit code alone, for a wire signal.\n\nExit codes: `0` — at least one fixture executed and none failed; `1` — any fixture failed; `2` — invocation error (bad arguments, an unreadable registry or projector, an unreachable server) **or** a zero-executed (all-skip) run. A run that proved nothing never reads as success.\n\n## What a failure means\n\n- **FAIL** — your implementation violates a protocol obligation the fixture freezes (wrong ack shape, missing `CONTRACT_VIOLATION` rejection, version-handshake drift, …). Path-A fails are server-side vendor-neutrality bugs: fix the implementation, not the fixture.\n- **WARN** — not a failure. A SHOULD fixture (`\"level\": \"should\"`) whose expectation your implementation does not meet: a recommendation it declined, with the same evidence a failure carries (`result.warned`). A warning never fails a run or changes the CLI's exit code. The bundled CLI has no host, so it skips every fixture that needs one, the SHOULD case included; a warning appears only in a programmatic run with a `host`.\n- **SKIP** — not a failure. Path-B (browser-host) claims and setup directives your host doesn't implement skip with a precise reason. But pin your pass, skip **and** warned sets as exact sets in CI: a fixture silently degrading to a skip, or a warning appearing unnoticed, should fail your build, and re-pinning should be a deliberate act.\n- **All-skip** — red (`exit 2` / the programmatic guard above). Zero executed fixtures carries no signal. Catalog rows handed in by flag count as executed, so with a catalog flag the exit code alone cannot tell you the wire fixtures ran — pin the wire fixtures' pass set as above.\n\nA change is **breaking** iff a fixture that passed against version N fails against N+1 — the kit, not opinion, is the arbiter (see [Version policy](/protocol/version-policy/)).\n\n## See also\n\n- [Conformance](/protocol/conformance/) — the contract/protocol bars the kit makes observable.\n- [`@ggui-ai/protocol-reference-server`](https://github.com/ggui-ai/ggui/tree/main/packages/protocol-reference-server) — a minimal target to sanity-check your CI wiring against before pointing the kit at your own implementation.\n- Downstream SDK integrations — including the [composed path on guuey's dev tooling](/quickstart/self-hosted/#path-1-composed-path-on-guueys-dev-tooling) — gate their side of the seam with this same kit in their own CI; the protocol itself requires no particular platform, and the kit runs against any implementation of the wire."},{"path":"/self-hosted/oauth.md","title":"OAuth (self-hosted)","description":"OAuth 2.1 + PKCE + Dynamic Client Registration wire format for a self-hosted @ggui-ai/mcp-server (enable with `oauth: true` / `ggui serve --oauth`), with the access-token-IS-the-API-key trade-off explained.","body":"---\ntitle: OAuth (self-hosted)\ndescription: OAuth 2.1 + PKCE + Dynamic Client Registration wire format for a self-hosted @ggui-ai/mcp-server (enable with `oauth: true` / `ggui serve --oauth`), with the access-token-IS-the-API-key trade-off explained.\n---\n\nA self-hosted [`@ggui-ai/mcp-server`](/quickstart/self-hosted/) — enabled with `oauth: true` on the factory or `ggui serve --oauth` — implements [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) with [PKCE](https://datatracker.ietf.org/doc/html/rfc7636), [Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591) (RFC 7591), the [Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) discovery profile (RFC 9728), and the [Resource Indicators](https://datatracker.ietf.org/doc/html/rfc8707) extension (RFC 8707) for per-app token scoping. MCP-aware hosts can connect with just a server URL — no manually-issued `client_id`, no shared secret. To onboard a remote host (Claude Desktop, claude.ai, Goose) against your local server, front it with a tunnel (ngrok / cloudflared) and pass `--public-base-url=<tunnel-url>` so the discovery URLs resolve from the host's browser.\n\nThis page is the wire reference. If you only want to **connect a client** ([Claude Desktop](/clients/claude-desktop/), claude.ai, Goose, VS Code Copilot), the host handles everything — skip this page. Read on if you're building a host, debugging a custom client, or operating a deployment of `@ggui-ai/mcp-server`.\n\n:::note[Why this exists]\nCurrent MCP spec drafts require remote MCP servers to surface OAuth-discoverable auth so hosts can onboard a server with just a URL. The OSS [`@ggui-ai/mcp-server`](/quickstart/self-hosted/) speaks the full flow when you enable it. The hosted endpoint at `mcp.ggui.ai` uses the same flow.\n:::\n\n## The flow at a glance\n\n```\nClient                            Server                            User\n  │                                 │                                 │\n  │── GET /mcp (no auth) ─────────→│                                 │\n  │← 401 WWW-Authenticate:         │                                 │\n  │   Bearer realm=\"mcp\",          │                                 │\n  │   resource_metadata=\"<url>\" ──│                                 │\n  │                                 │                                 │\n  │── GET /.well-known/             │                                 │\n  │   oauth-protected-resource ───→│                                 │\n  │← { authorization_servers } ───│                                 │\n  │                                 │                                 │\n  │── GET /.well-known/             │                                 │\n  │   oauth-authorization-server ─→│                                 │\n  │← { authorize_endpoint, ... } ─│                                 │\n  │                                 │                                 │\n  │── POST /oauth/register ───────→│  (RFC 7591 DCR — no client_secret)\n  │← { client_id } ───────────────│                                 │\n  │                                 │                                 │\n  │── open /oauth/authorize ──────→│── in-process consent form ───→│\n  │                                 │                                 │── paste key / pair code + Approve\n  │                                 │← form POST {api_key, params} ─│\n  │← 302 → redirect_uri?code=… ───│                                 │\n  │                                 │                                 │\n  │── POST /oauth/token            │                                 │\n  │   {code, code_verifier} ──────→│                                 │\n  │← { access_token: <api-key> } ─│                                 │\n  │                                 │                                 │\n  │── GET /mcp                     │                                 │\n  │   Authorization: Bearer       ─→│                                 │\n  │← MCP session ─────────────────│                                 │\n```\n\n## The access token IS the API key\n\nThe simplest possible bridge between MCP's OAuth-required client UX and GGUI's existing API-key model:\n\n- **No parallel token table.** The `access_token` returned at `/oauth/token` is verbatim the bearer key your `AuthAdapter` accepts on `/mcp` — pasted (or, with `ggui serve`, exchanged from the terminal pair code) during consent.\n- **No translation in the request hot path.** An authenticated `/mcp` request looks identical to a static-bearer request — `Authorization: Bearer <api-key>`.\n- **No refresh dance.** The access token TTL equals the API key TTL. Revoke the key in your store and the client's next request returns `401`. Re-auth is one OAuth round trip — one paste from the user.\n- **One audit surface.** Every connected client appears as a single row in your keys list, labelled with the `client_name` from DCR.\n\nThe trade-off: there's no \"this token came from OAuth\" flag — the key works identically whether minted by your operator tooling or the OAuth flow. If that distinction matters to your operator policy, plug in your own `OAuthStorage` + `AuthAdapter` (see [Storage seam](#storage-seam) below).\n\n## Endpoints\n\n### `GET /.well-known/oauth-protected-resource` (RFC 9728)\n\nTells the client where to find the authorization server. Same origin in our case — your server (here a tunnel exposing the local `ggui serve`) is both the resource and the auth server.\n\n```json\n{\n  \"resource\": \"https://your-mcp.example.com/mcp\",\n  \"authorization_servers\": [\"https://your-mcp.example.com\"],\n  \"bearer_methods_supported\": [\"header\"],\n  \"resource_documentation\": \"https://modelcontextprotocol.io/extensions/apps/overview\"\n}\n```\n\n### `GET /.well-known/oauth-authorization-server` (RFC 8414)\n\nAuthorization-server metadata.\n\n```json\n{\n  \"issuer\": \"https://your-mcp.example.com\",\n  \"authorization_endpoint\": \"https://your-mcp.example.com/oauth/authorize\",\n  \"token_endpoint\": \"https://your-mcp.example.com/oauth/token\",\n  \"registration_endpoint\": \"https://your-mcp.example.com/oauth/register\",\n  \"response_types_supported\": [\"code\"],\n  \"grant_types_supported\": [\"authorization_code\"],\n  \"code_challenge_methods_supported\": [\"S256\"],\n  \"token_endpoint_auth_methods_supported\": [\"none\"],\n  \"scopes_supported\": [\"mcp\"],\n  \"authorization_response_iss_parameter_supported\": true\n}\n```\n\n`token_endpoint_auth_methods_supported: [\"none\"]` is intentional — PKCE is the only supported client authentication, no `client_secret` is ever issued.\n\n### `POST /oauth/register` (RFC 7591)\n\nDynamic Client Registration. Issues a random `client_id`, no secret. Registration is **open** — there is no allowlist of redirect URIs. The MCP authorization spec's current revision (2026-07-28) permits DCR as a deprecated fallback and recommends Client ID Metadata Documents, which this server does not support yet; an MCP client falls back to DCR when the server advertises no `client_id_metadata_document_supported`, so today every host registers here before its user has ever seen your server. The door is not the control; these are, and each is pinned by a test in `@ggui-ai/mcp-server` (`oauth-dcr.test.ts`, ggui#1174):\n\n- `redirect_uris` are shape-checked: absolute; no fragment; `https`, or `http` on a loopback host (`127.0.0.1`, `[::1]`, `localhost` — RFC 8252 §7.3), or a private-use scheme such as `cursor://…` / `com.example.app:/…` (RFC 8252 §7.1) that a browser cannot execute (`javascript:`, `data:`, `blob:`, `file:`, `about:` are refused); no wildcard host; at most 10 entries. One bad entry refuses the whole registration with `400 invalid_redirect_uri`.\n- `client_name` is optional, at most 100 printable characters (`400 invalid_client_metadata`) — the consent page prints it.\n- `/oauth/authorize` accepts only a `redirect_uri` the client registered, by **exact** string match, and only `code_challenge_method=S256`.\n- `/oauth/token` binds the code to the client, the redirect URI and the PKCE challenge; the code is single-use with a 5-minute TTL; the verifier check is constant-time.\n- The consent page names the registering client and the host the code will be sent to **before** it asks for a pair code or key — the user is the last control, and an informed one.\n- The route is rate-limited per IP: **10 registrations per 10 minutes**; the 11th gets `429` with `Retry-After` and `{ error: { code: \"rate_limited\" } }`, and the server logs `rate_limit_hit` with `quotaKey: \"oauth-register\"`. Behind a proxy, set `trustProxy: true` so the bucket follows `X-Forwarded-For` rather than the proxy's address — **only** if that proxy overwrites the header from the connection it accepted (ingress-nginx: `use-forwarded-headers: \"false\"`); behind a proxy that appends to a client-sent header, the first hop is whatever the caller wrote and the caller picks its own bucket.\n\n```bash\ncurl -X POST https://your-mcp.example.com/oauth/register \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"redirect_uris\": [\"http://localhost:33418/callback\"],\n    \"client_name\": \"Claude Desktop\"\n  }'\n```\n\n```json\n{\n  \"client_id\": \"mcp_client_AbCdEf...\",\n  \"redirect_uris\": [\"http://localhost:33418/callback\"],\n  \"grant_types\": [\"authorization_code\"],\n  \"response_types\": [\"code\"],\n  \"token_endpoint_auth_method\": \"none\",\n  \"client_name\": \"Claude Desktop\"\n}\n```\n\n### `GET /oauth/authorize`\n\nThe user-facing approval entry point. Required query params:\n\n| Param                   | Value                                                                                                      |\n| ----------------------- | ---------------------------------------------------------------------------------------------------------- |\n| `response_type`         | `code`                                                                                                     |\n| `client_id`             | from DCR                                                                                                   |\n| `redirect_uri`          | one of the URIs registered with DCR                                                                        |\n| `code_challenge`        | base64url(SHA256(verifier))                                                                                |\n| `code_challenge_method` | `S256`                                                                                                     |\n| `state`                 | opaque, echoed back on redirect (CSRF defense)                                                             |\n| `scope`                 | `mcp` (advertised; not gated server-side today)                                                            |\n| `resource`              | optional — RFC 8707 indicator naming the target MCP endpoint (per-app routing); omit for universal scoping |\n\nWith no `consentUrl` configured (the typical self-hosted posture), the server renders an unbranded paste-key HTML form in-process — no external origin needed. When the operator does configure a `consentUrl`, the server 302s the browser there with every OAuth param forwarded plus an `mcp_origin` param the consent page uses to know where to POST back. (The hosted endpoint points `consentUrl` at its own console.)\n\n### `POST /oauth/authorize` (form-encoded)\n\nCalled by the consent UI (the in-process form by default) after the user approves. Same OAuth params echoed as hidden inputs, plus one of two credential fields:\n\n- `pair_code` — the 6-digit code printed on the terminal banner by `ggui serve`. The server exchanges it through `PairingService.completePairing` to mint a per-server bearer. This is the easiest self-hosted path.\n- `api_key` — a freshly-minted (or pasted) bearer key your `AuthAdapter` accepts.\n\nThe server validates the resulting key against the same `AuthAdapter` that gates `/mcp`, mints a 5-minute auth code, and 302s to the client's `redirect_uri?code=…&state=…&iss=…`. `iss` (RFC 9207) is byte-identical to the metadata's `issuer`; a client compares the two before it sends the code to a token endpoint (the mix-up defence). Because the metadata advertises `authorization_response_iss_parameter_supported: true`, a `consentUrl` page you host MUST add `iss` too when it redirects to the client itself — for example `error=access_denied` on a cancel — using the `mcp_origin` value the server hands it; a conforming client rejects an authorization response without `iss`.\n\n### `POST /oauth/token`\n\nExchange the auth code for an access token.\n\n```bash\ncurl -X POST https://your-mcp.example.com/oauth/token \\\n  -H \"Content-Type: application/x-www-form-urlencoded\" \\\n  -d \"grant_type=authorization_code&code=AUTH_CODE&redirect_uri=http://localhost:33418/callback&client_id=mcp_client_…&code_verifier=ORIGINAL_VERIFIER\"\n```\n\n```json\n{\n  \"access_token\": \"<api-key>\",\n  \"token_type\": \"Bearer\",\n  \"scope\": \"mcp\"\n}\n```\n\nNo `refresh_token`, no `expires_in` — the access token's lifetime is the underlying API key's lifetime. Re-auth to get a new one.\n\nIf the client passed a `resource` at `/authorize` and includes one at `/token`, the two MUST match (RFC 8707 §2.2); mismatch returns `400 invalid_target`. Omitting `resource` at `/token` is tolerated even when the auth code captured one — RFC 8707 only mandates the constraint when the client opts in.\n\n## Resource indicators (RFC 8707)\n\nThe server supports per-app token scoping via the optional `resource` query parameter at `/oauth/authorize`. Two shapes are accepted:\n\n- **Universal** — `${issuer}` (cloud bare root) or `${issuer}/mcp` (OSS default path). Tokens bind to the user's full app surface.\n- **Per-app** — `${issuer}/apps/<appId>` where `<appId>` matches the deployment's app-id pattern. The auth code, and any token minted from it, is captured against that single app target. Per-app deployments also surface their own `/.well-known/oauth-protected-resource` at the app-scoped path so RFC 9728 discovery resolves correctly when a client starts from the per-app URL.\n\nAbsent `resource`, universal scoping applies. Unknown resources are rejected at `/authorize` with `invalid_target` (RFC 8707 §2) before the consent step so the user sees the failure before pasting a key. OSS deployments can plug a `validateResource(issuer, resource)` callback into the server factory to gate which targets are accepted.\n\nThe captured resource is snapshotted onto the DCR client record (`lastResource`) so an operator console can label rows \"Connected to: &lt;App&gt;\" rather than \"Universal\".\n\n## OSS deployment notes\n\n**Running the CLI?** `ggui serve --oauth` mounts this whole surface — the `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server` discovery endpoints plus `/oauth/{authorize,token,register}`. It's required for OAuth-discovery hosts (claude.ai / ChatGPT \"Add custom connector\"); pure-bearer clients work without it. The paste-key form accepts either a `ggui_user_*` key or the 6-digit pair code from the serve banner.\n\nIf you're embedding [`@ggui-ai/mcp-server`](/quickstart/self-hosted/) programmatically, pass `oauth: true` (or an `OAuthConfig`) to the server factory:\n\n```typescript\nimport { createGguiServer } from \"@ggui-ai/mcp-server\";\n\nconst server = createGguiServer({\n  // ...\n  oauth: {\n    issuerUrl: \"https://your-mcp.example.com\",\n    consentUrl: \"https://your-console.example.com/oauth/consent\", // optional\n    storage: new InMemoryOAuthStorage(), // or your own\n  },\n  auth: yourAuthAdapter,\n});\n```\n\n### Storage seam\n\nAuth codes and DCR clients live in `OAuthStorage`. The default `InMemoryOAuthStorage` is fine for single-replica dev and any deployment with sticky sessions on the load balancer. Multi-replica deployments without sticky sessions need a shared backend — Redis or DynamoDB both work. The interface is two collections (auth codes, DCR clients) with `put` / `consume` / `get` methods; see `packages/mcp-server/src/oauth.ts` in the [public repo](https://github.com/ggui-ai/ggui) for the full type.\n\n### Consent UI\n\n`consentUrl` delegates the user-facing approval step to a separate origin. The consent UI sees both the user's session (whatever auth model it uses) and the freshly-minted API key in plaintext — same trust boundary as the MCP server itself. Validate the `mcp_origin` param against an allowlist before posting back, otherwise an attacker can craft a URL that exfiltrates a key to a third-party origin.\n\nWithout `consentUrl`, the server renders an in-process paste-key HTML form. Functional but unbranded — fine for OSS deployers who don't want to stand up a separate origin.\n\n### Disable OAuth entirely\n\n`oauth: false` (or simply omitting the option) skips the OAuth surface — `/oauth/*` and the well-known endpoints return 404, and `/mcp` falls back to the bearer-only `AuthAdapter` model. Hosts that don't speak OAuth-DCR can still authenticate by setting a static `Authorization: Bearer …` header.\n\n## Hosted GGUI\n\nThe hosted endpoint at `mcp.ggui.ai` runs this identical flow with two deployment-specific choices: `consentUrl` points at its own console's consent page (so approval happens on a branded origin that mints a `ggui_user_*` key for you), and the universal MCP endpoint mounts at the bare root rather than `/mcp`.\n\n## Non-goals\n\n- **Not refresh-token compatible.** The OAuth ceremony is one-time per credential. Adding a refresh-token grant requires decoupling the access token from the API key (separate token store + lifetime).\n- **Not OIDC.** No ID tokens, no userinfo endpoint, no nonce. The MCP spec doesn't ask for them, and the access-token-IS-the-API-key model doesn't need them.\n- **Not a replacement for static API keys.** If your client can hold a static `Authorization: Bearer <api-key>` header (CLI agents, server-side runtimes), keep doing that. For local dev, `Authorization: Bearer dev` works against `ggui serve --dev-allow-all`. OAuth exists for hosts that need DCR + browser-mediated approval."},{"path":"/self-hosted/pairing.md","title":"Pair a client to a self-hosted server","description":"Pair a viewer client to a GGUI server you run yourself — no account, no hosted backend.","body":"---\ntitle: Pair a client to a self-hosted server\ndescription: Pair a viewer client to a GGUI server you run yourself — no account, no hosted backend.\n---\n\n:::note[This is the self-hosted path]\nYou run `ggui serve` on your own machine or hardware. Paired clients connect to it directly — no account, no hosted backend.\n\nPrefer hosted GGUI instead? See [the GGUI console](/hosted/console/).\n:::\n\nIf you followed the [self-hosted quickstart](/quickstart/self-hosted/), you already have a local `ggui serve` running. This page pairs a viewer client to it, so the client can browse agents, start chats, and view generated UIs while the server stays on your infrastructure. The pairing protocol below is live today and works with any client you build (the curl flow is the primary path). guuey's companion app is **live on Android** ([Google Play](https://play.google.com/store/apps/details?id=com.guuey.app)), with iOS coming soon — it wraps this exact flow, see [In the guuey app](#in-the-guuey-app-android).\n\n## Big picture\n\n```\nYour MCP agent ── MCP ──→ ggui serve (your infra)\n                              │\n                              │  POST /admin/pair/init (admin)  → {code, codeExpiresAt, serverName}\n                              │  POST /pair              (public) → {pairingId, token, serverName, deviceName}\n                              │  /ws (live channel, bearer-gated)\n                              ▼\n                         Paired viewer client\n```\n\nPairing exchanges a one-shot code for a bearer token (no built-in expiry; lives until you revoke it server-side). The client stores the token and sends it on every subsequent call. Your server is the only backend involved — it never talks to `mcp.ggui.ai` or any other external service on your behalf.\n\n## Prerequisites\n\n- A running `ggui serve` that your client can reach over HTTP/HTTPS + WebSocket. On localhost that means serving on `127.0.0.1` and opening the client on the same machine; across the network, see [Reaching your server from a phone](#reaching-your-server-from-a-phone).\n- An operator session on the server — i.e., you can mint codes. By default `ggui serve` prints a fresh `ggui_admin_*` admin token on boot (pin it with `--admin-token <t>` to survive restarts); that bearer gates `POST /admin/pair/init`. With `--dev-allow-all` any bearer — or none at all — is accepted as `builder` — fine on localhost, unsafe anywhere else (see [Hardening](#hardening-before-leaving-localhost)).\n\n## Step 1: Mint a pairing code\n\nThe server never hands out a bearer token without first minting a code. The code is the proof-of-presence for the first exchange.\n\n### From the operator console\n\n`ggui serve` exposes a minimal operator UI at `http://127.0.0.1:6781/`. Click **Start pairing** — the console calls `POST /admin/pair/init` with the current session bearer and displays a 6-digit code with an expiry timer (default: 10 minutes).\n\n### From the command line\n\nIf you prefer a headless flow (CI, scripts, tmux):\n\n```bash\ncurl -sS -X POST http://127.0.0.1:6781/admin/pair/init \\\n  -H \"Authorization: Bearer $GGUI_ADMIN_TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"serverName\":\"my laptop\"}'\n```\n\nReplace `$GGUI_ADMIN_TOKEN` with the `ggui_admin_*` printed on the boot banner (or whatever you passed to `--admin-token`). With `--dev-allow-all`, any bearer — or none at all — works.\n\nResponse:\n\n```json\n{\n  \"code\": \"472301\",\n  \"codeExpiresAt\": \"2026-04-20T12:34:56.000Z\",\n  \"serverName\": \"my laptop\"\n}\n```\n\nTreat the code like a one-time password — anyone who gets it in the next 10 minutes can pair. It's consumed on the first successful `POST /pair`.\n\nIf the code expires before the client finishes pairing, `POST /pair` returns `401 {error: {code: 'pairing_rejected', …}}`. There's no auto-refresh — re-run `POST /admin/pair/init` to mint a fresh code and try again. Only one outstanding code exists at a time (a second `init` overwrites the pending one).\n\n## Step 2: Exchange the code for a bearer\n\nEvery client performs the same exchange — `POST /pair` (public, no auth — pairing IS the bootstrap for future auth) with the code and a device name:\n\n```bash\ncurl -sS -X POST http://127.0.0.1:6781/pair \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"code\":\"472301\",\"deviceName\":\"my client\"}'\n```\n\nResponse:\n\n```json\n{\n  \"pairingId\": \"…\",\n  \"token\": \"…\",\n  \"serverName\": \"my laptop\",\n  \"deviceName\": \"my client\"\n}\n```\n\nStore the `token` and send it as `Authorization: Bearer <token>` on the server's MCP (`/mcp`) and live-channel WebSocket (`/ws`) endpoints.\n\n### In the guuey app (Android)\n\nThe guuey app ([Android on Google Play](https://play.google.com/store/apps/details?id=com.guuey.app); iOS coming soon) wraps this exchange in **Settings → Servers → Add server** — enter the server URL (`http://127.0.0.1:6781` or the reachable hostname), the 6-digit pairing code, and a device name, then tap **Pair**. The app stores the token and lists the server under **Settings → Servers** with a live reachability dot (green reachable, amber token-invalid, red unreachable), where you can switch, rename, remove, or re-pair. The curl flow above remains the path for clients you build yourself.\n\n## Step 3: Talk to your server\n\nWith the bearer paired, a client can:\n\n- **List agents** declared in your server's `ggui.json#agent` entry — OSS default is the single supervised agent process `ggui serve` boots alongside MCP; no marketplace.\n- **Start chats** over the live-channel WebSocket at `ws://<your-server>/ws` (or `wss://` once you front it with TLS). Messages flow through the paired MCP transport; chat history lives in your server's session store.\n- **View generated UIs** — when an agent calls `ggui_render`, the result is delivered as an MCP-Apps resource (`ui://ggui/render/<sessionId>`) your server serves. The client mounts it inline (same-origin cookies on your server, your CSP).\n\nA paired client does NOT proxy any of this — your server is the only backend involved.\n\n## Managing pairings\n\n- **Revoke server-side:** The operator console has a **Paired devices** list — revoking there calls `POST /admin/pair/:pairingId/revoke` (admin bearer required, idempotent). The token is removed from the active `AuthAdapter`, so the next `/mcp` or `/ws` call from that device fails with `401 No valid credentials` and the client must re-pair.\n- **Rotate a lost token:** If you suspect a token leaked, revoke the server-side entry and re-run Step 1 / Step 2.\n\n## Reaching your server from a phone\n\nLocalhost-only by default. To pair from a phone on the same LAN:\n\n1. Bind `ggui serve` to your LAN address (or `0.0.0.0`), not `127.0.0.1`:\n\n   ```bash\n   pnpm exec ggui serve --host 0.0.0.0 --port 6781\n   ```\n\n2. Note your LAN IP (e.g., `192.168.1.42`) and use `http://192.168.1.42:6781` as the server URL in your client.\n\nIf the phone is NOT on the same network (mobile data, different NATs), you need a public endpoint. `ggui serve` doesn't ship a managed tunnel — use any of:\n\n- **[ngrok](https://ngrok.com)** — `ngrok http 6781` and use the `https://…ngrok-free.app` URL.\n- **[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)** — `cloudflared tunnel --url http://localhost:6781`.\n- **[Tailscale](https://tailscale.com)** — put both devices on your tailnet; use the MagicDNS hostname.\n\nAny of these makes pairing work across arbitrary networks without exposing your port to the open internet. Once you have a public `https://` URL, the live-channel WebSocket automatically upgrades to `wss://`.\n\n## Hardening before leaving localhost\n\n`ggui serve` defaults to strict-auth: `/mcp` only accepts pair-minted bearers, and `/admin/pair/init` requires the per-boot `ggui_admin_*` token. With `--dev-allow-all` it relaxes to accept any bearer — or none at all — as `builder` — fine on `127.0.0.1`, unsafe anywhere else.\n\nBefore you put the server on a public URL:\n\n1. **Swap in a real `AuthAdapter`.** `createGguiServer({ auth })` accepts a custom adapter that gates both the MCP endpoint and the live-channel WebSocket. See [`@ggui-ai/mcp-server-core`](https://www.npmjs.com/package/@ggui-ai/mcp-server-core) for the `AuthAdapter` contract.\n2. **Terminate TLS in front.** A reverse proxy (Caddy, nginx, Cloudflare Tunnel) is the expected shape. The bare `ggui serve` port is plaintext HTTP.\n3. **Firewall the admin paths.** `POST /admin/pair/init` is the only surface that mints codes. Block the `/admin/` prefix from the public internet with one reverse-proxy rule; keep it on a local admin interface or VPN.\n4. **Rotate pairing tokens.** Revoke paired devices from the operator console when they're no longer in use.\n\n## What this does NOT include\n\n- **Workspaces, billing, usage metering, team management.** guuey-hosted SaaS surfaces. They do not exist on a self-hosted server.\n- **Managed (operator-paid) generation.** Generation on a self-hosted server uses YOUR provider key (BYOK) — set `ANTHROPIC_API_KEY` (or another provider key) and `ggui.json#generation.model`, or paste a key at `/settings`. See [`ggui serve` → Generation](/cli/serve/#generation-bring-your-own-key).\n- **Push notifications, mobile background sync, agent marketplace.** Not part of the self-hosted server today.\n\n## What's next\n\n- **[Self-hosted quickstart](/quickstart/self-hosted/)** — fastest path from zero to a running local server.\n- **[Reference deploys](/self-hosted/reference-deploys/)** — Docker / Fly.io / Render manifests for putting `ggui serve` on a public URL.\n- **[MCP Protocol Reference](/api/mcp-protocol/)** — wire format, identical across OSS and hosted.\n- **[WebSocket Protocol](/api/websocket-protocol/)** — live-channel envelopes (`ActionEnvelope`, `StreamEnvelope`)."},{"path":"/self-hosted/reference-deploys.md","title":"Reference deploys — Docker, Fly.io, Render","description":"Copy-paste Dockerfile, fly.toml, and render.yaml manifests for standing up `ggui serve` on a public URL.","body":"---\ntitle: Reference deploys — Docker, Fly.io, Render\ndescription: Copy-paste Dockerfile, fly.toml, and render.yaml manifests for standing up `ggui serve` on a public URL.\n---\n\n:::note[Self-hosted path]\nThese manifests boot `ggui serve` — the open GGUI protocol server. They do NOT require an account.\n\nPrefer not to run your own infrastructure? See [hosted GGUI](/hosted/console/).\n:::\n\nIf you followed the [self-hosted quickstart](/quickstart/self-hosted/), you have `ggui serve` running on localhost. This page puts that same server behind a public URL so your phone, teammates, or collaborators can reach it. Three drop-in manifests — generic Docker, Fly.io, Render.com — are inlined below; copy them straight into your project root.\n\nNone of these deploys phone home. Your code runs on your infrastructure. Viewer clients pair with the public URL via the flow in [Self-hosted pairing](/self-hosted/pairing/).\n\n## What's in the box\n\n| File          | Purpose                                                             |\n| ------------- | ------------------------------------------------------------------- |\n| `Dockerfile`  | Multi-stage Node 22 image that boots `ggui serve` on `$PORT`.       |\n| `fly.toml`    | Fly.io manifest — scales to zero, healthchecks `/ggui/health`.      |\n| `render.yaml` | Render.com Blueprint — Docker runtime, healthchecks `/ggui/health`. |\n\nAll three are self-contained — there is no separate templates package to install.\n\n## Option 1 — Generic Docker\n\nWorks anywhere Docker runs: EC2, your homelab, a Raspberry Pi, a Coolify VPS. Save this as `Dockerfile` in your project root (next to `ggui.json`):\n\n```dockerfile\n# syntax=docker/dockerfile:1\n# Boots `ggui serve` on $PORT. Assumes your project root has\n# package.json + ggui.json (+ agent entry and blueprints).\nFROM node:22-slim AS deps\nWORKDIR /app\nCOPY package.json package-lock.json* ./\nRUN npm install --omit=dev\n\nFROM node:22-slim\nWORKDIR /app\nENV NODE_ENV=production\nCOPY --from=deps /app/node_modules ./node_modules\nCOPY . .\nEXPOSE 6781\nCMD [\"sh\", \"-c\", \"npx -y @ggui-ai/cli serve --host 0.0.0.0 --port ${PORT:-6781}\"]\n```\n\n```bash\ndocker build -t my-ggui .\ndocker run --rm -p 6781:6781 -e PORT=6781 -e ANTHROPIC_API_KEY=sk-... my-ggui\n```\n\nThe image exposes `/ggui/health`, `/mcp` (MCP channel), `/ws` (WebSocket channel for `ActionEnvelope` / `StreamEnvelope`), `/pair`, `/admin/pair/init`, `/admin/pair/:pairingId/revoke`, and `/` (operator console) on `$PORT`. Once running, point a viewer client at `http://<docker-host>:6781` and follow the [Pairing guide](/self-hosted/pairing/).\n\n### TLS\n\n`ggui serve` binds plaintext HTTP. Put a TLS-terminating reverse proxy in front (Caddy, nginx, Cloudflare Tunnel) for anything beyond a LAN. The WebSocket channel rides the same `:6781` port — make sure your proxy upgrades `/ws` correctly. Minimal Caddy example (Caddy handles WS upgrades automatically):\n\n```text\nmy-ggui.example.com {\n  reverse_proxy 127.0.0.1:6781\n}\n```\n\n## Option 2 — Fly.io\n\nFly gives you a free-tier region, HTTPS by default, and scale-to-zero. Reuse the `Dockerfile` from Option 1 and save this as `fly.toml`:\n\n```toml\n# fly.toml — `ggui serve` reference deploy\napp = \"my-ggui\"          # change me\nprimary_region = \"iad\"   # change me\n\n[build]\n  dockerfile = \"Dockerfile\"\n\n[env]\n  PORT = \"6781\"\n\n[http_service]\n  internal_port = 6781\n  force_https = true\n  auto_stop_machines = true\n  auto_start_machines = true\n  min_machines_running = 0\n\n  [[http_service.checks]]\n    interval = \"30s\"\n    timeout = \"5s\"\n    grace_period = \"10s\"\n    method = \"GET\"\n    path = \"/ggui/health\"\n```\n\n```bash\nfly launch --no-deploy   # picks up the fly.toml + Dockerfile above\nfly secrets set ANTHROPIC_API_KEY=sk-...\nfly deploy\n```\n\nThe `[http_service]` block sets `force_https = true`, so the URL you hand a viewer client is `https://<app>.fly.dev` — no proxy setup needed. The WS channel rides the same TLS endpoint as `wss://<app>.fly.dev/ws`.\n\n`auto_stop_machines = true` + `min_machines_running = 0` freezes the machine when idle and thaws it on the next request (~1–2 s cold start). Fine for a personal server. Disable it if you need zero-latency persistence — e.g., a long-running session where a cold start mid-conversation would drop the WS connection.\n\n## Option 3 — Render.com\n\nRender is the simplest click-to-deploy story. Commit the manifest + Dockerfile, point Render at your repo, and it redeploys on every push to `main`. Reuse the `Dockerfile` from Option 1 and save this as `render.yaml`:\n\n```yaml\n# render.yaml — `ggui serve` reference deploy\nservices:\n  - type: web\n    name: my-ggui\n    runtime: docker\n    plan: starter\n    healthCheckPath: /ggui/health\n    envVars:\n      - key: ANTHROPIC_API_KEY\n        sync: false # set the value in the Render dashboard\n```\n\n```bash\ngit add Dockerfile render.yaml\ngit commit -m \"chore: add ggui serve reference deploy\"\ngit push\n```\n\nIn Render's dashboard: **New → Blueprint → connect your repo**. Render reads `render.yaml`, provisions the service, and gives you a `https://<name>.onrender.com` URL (TLS + WS upgrade handled).\n\nSwap `plan: starter` for `standard` if your agent does heavier in-process work — embeddings, large prompt assembly, or holding many concurrent WebSocket sessions.\n\n## Environment variables\n\n`ggui serve` honors a handful of env vars across all three deploys. Set them via the PaaS's secret store (Fly secrets, Render environment groups, `docker run -e`):\n\n| Variable                | Effect                                                                                                                                                                                                                                                                                                                          |\n| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `PORT`                  | Bind port. Injected by Fly + Render automatically; pass `-e PORT=…` for raw Docker. The Dockerfile's CMD forwards it via `--port ${PORT:-6781}`.                                                                                                                                                                                |\n| LLM provider key        | Set one of `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GOOGLE_API_KEY` / `OPENROUTER_API_KEY` via the PaaS secret store, matching `ggui.json#generation.model`. Without one, every render falls back to a Connect-a-key card pointing at `/settings`. See [`ggui serve` → Generation](/cli/serve/#generation-bring-your-own-key). |\n| `GGUI_PERSISTENT_DIR`   | Override the `.ggui/persistent/` bundle location (HMAC secrets, sessions, vectors, short-codes, paired bearers). Defaults to project-local; point at a mounted volume for durability.                                                                                                                                           |\n| `GGUI_MCP_INSTRUCTIONS` | Server-level MCP instructions preset (`default` / `aggressive` / `always` / `minimal` / `off`); unset sends none. CLI flag `--mcp-instructions` wins if both are set.                                                                                                                                                           |\n\nPin `--admin-token` via the CMD (e.g. `ggui serve --admin-token \"$GGUI_ADMIN_TOKEN\" --host 0.0.0.0 --port $PORT`) when you want the admin bearer to survive restarts; otherwise the server mints a fresh one each boot.\n\n## Before you leave localhost\n\nThe default `ggui serve` posture is **strict-auth**: `/mcp` only accepts pair-minted bearers, and `/admin/pair/init` requires the per-boot `ggui_admin_*` token (or whatever you pin via `--admin-token`). That's already safe to expose. The hardening below covers the remaining edges before handing out a public URL to paired clients:\n\n1. **Pin the admin token.** Without `--admin-token`, the server mints a fresh `ggui_admin_*` per boot — fine for local use, awkward when you redeploy. Pin a stable value via a secret (e.g. Fly secrets, Render environment groups) and pass it as `--admin-token \"$GGUI_ADMIN_TOKEN\"` in the CMD.\n2. **(Optional) swap in a real `AuthAdapter`.** If you want OIDC / Cognito / SSO instead of pair-minted bearers, wrap your agent entrypoint with `createGguiServer({ auth })` that gates `/mcp` and `/ws`. See the `AuthAdapter` interface in [`@ggui-ai/mcp-server-core`](https://www.npmjs.com/package/@ggui-ai/mcp-server-core).\n3. **Firewall `/admin/*`.** `/admin/pair/init` and `/admin/pair/:pairingId/revoke` are the only admin surfaces today, both bearer-gated by the admin token. Restricting the `/admin/` prefix to an allow-list IP or VPN at the reverse-proxy layer adds defense-in-depth (and covers any new `/admin/*` routes added later).\n4. **NEVER pass `--dev-allow-all` or `--public-demo` here.** Those flags relax `/mcp` to accept any bearer — or none at all (the second one adds a rate limiter and a \"operator pays\" banner). They exist for local-dev and demo audiences, not for a deployed server. `--public-base-url` is for ad-hoc tunnel testing, not for these manifests — the PaaS already gives you the public URL.\n5. **Rotate pairings.** Pair-minted bearers have no built-in expiry — they live until revoked. Revoke stale paired devices from the operator console at `/` (or `POST /admin/pair/:pairingId/revoke` directly).\n\nSkip these and a \"deployed ggui serve\" still authenticates correctly, but you'll be churning admin tokens on every redeploy and can't selectively block admin-only routes at the edge.\n\n## What this does NOT include\n\n- **Managed deploys.** Reference assets only — no auto-updates, no managed rollouts, no dashboard beyond what the PaaS ships. Render's built-in logs/metrics gets closest to \"push to main → zero-downtime rollout with observability\"; full production SRE is out of scope.\n- **Multi-region replication.** Each deploy is a single instance. `ggui serve` is stateful-per-process today; replicas won't share session state, [blueprint](/glossary/) cache, or paired-device records without a shared store. `storage.renders` in `ggui.json` supports sqlite for a single node; durable multi-instance storage is roadmap.\n- **Hosted ggui.** These manifests are for running your own infrastructure. Hosted GGUI (`mcp.ggui.ai`) handles builds, logs, quotas, BYOK, and billing for you — see [Build an agent on hosted GGUI](/quickstart/hosted-agent/).\n\n## What's next\n\n- **[Self-hosted pairing](/self-hosted/pairing/)** — pair viewer clients to your deployed server.\n- **[Self-hosted quickstart](/quickstart/self-hosted/)** — the local-first walkthrough (run on localhost before you deploy)."},{"path":"/troubleshooting.md","title":"Troubleshooting","description":"Symptom-first index for the common GGUI failures — auth, transport reachability, session expiry, component render, tool registry, and JSON-RPC error codes.","body":"---\ntitle: Troubleshooting\ndescription: Symptom-first index for the common GGUI failures — auth, transport reachability, session expiry, component render, tool registry, and JSON-RPC error codes.\n---\n\nA symptom-first index. Skim until you find the error string you're seeing, then jump to the linked deep-dive. For typed handling of every protocol error class in code, the [Error Handling cookbook](/cookbook/error-handling/) is the canonical reference — this page is the lookup table. The authoritative numeric error table lives in [MCP Protocol → Error Codes](/api/mcp-protocol/#error-codes).\n\n## Is the MCP server reachable?\n\nGGUI speaks plain MCP. Connect with any MCP-compliant client. The minimum smoke-test using the official TypeScript SDK:\n\n```typescript\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\n\nconst transport = new StreamableHTTPClientTransport(new URL(\"http://127.0.0.1:6781/mcp\"), {\n  requestInit: { headers: { Authorization: \"Bearer dev\" } },\n});\n\nconst client = new Client({ name: \"ggui-smoke\", version: \"0.0.0\" });\nawait client.connect(transport);\nconst { tools } = await client.listTools();\nconsole.log(\n  \"ok\",\n  tools.map((t) => t.name)\n);\n```\n\nIf `connect()` rejects, the transport never produced a successful HTTP response — diagnose with the [HTTP status table](#http-status-codes) below. If `connect()` succeeds but `listTools()` rejects, you have a JSON-RPC error — see [JSON-RPC error codes](#json-rpc-error-codes).\n\nThe snippet assumes a local `ggui serve --dev-allow-all`, where any non-empty bearer (e.g. `Bearer dev`) works; the strict default requires a pair-minted bearer. Hosted cloud: use `https://mcp.ggui.ai/apps/<appId>` — no `/mcp` suffix — with a `ggui_user_*` connector key.\n\n---\n\n## Authentication\n\n### `401 Unauthorized` from the MCP transport\n\n- Connector keys start with `ggui_user_` — verify the prefix and that no characters were lost to a copy-paste.\n- Hosted GGUI keys (cloud) are environment-scoped. A `sandbox` key will not work against the production project and vice versa.\n- On the hosted cloud, rotate the key in [console.ggui.ai](https://console.ggui.ai) (apps → keys) if you suspect leakage.\n- Self-hosted `ggui serve` defaults to strict pairing-based auth — a `401` means your bearer wasn't pair-minted; use the pairing flow or `--dev-allow-all` on localhost. If you wrapped it behind your own auth, double-check the header your reverse proxy expects.\n\n→ Recovery patterns: [Error Handling cookbook](/cookbook/error-handling/).\n\n### `403 Forbidden` (capability denied)\n\nThe key authenticated but is not authorised for this app or method. It surfaces as a bare `403` whose JSON-RPC body carries `-32007` — see the [HTTP table](#http-status-codes); `-32005` never had a first-party emitter and is retired-reserved (GGUI#910).\n\n---\n\n## Connection\n\n### WebSocket connection stuck on `'reconnecting'`\n\nThe render runs in a sandboxed iframe (`<AppRenderer>`); the iframe-runtime owns the live-channel WebSocket and reconnects with exponential backoff (1 s → 30 s). After the retry budget exhausts it latches at `'disconnected'`. To resume:\n\n- Remount the `<AppRenderer>` — a fresh boot re-runs the bootstrap and opens a new socket.\n- Inspect your network path. Many corporate proxies strip the `Upgrade: websocket` header silently; the symptom is upgrade requests that never return `101 Switching Protocols` in the network tab.\n\nThe `wsUrl` the iframe connects to is server-stamped on the render's `ai.ggui/render` slice (`ws://` on the local `ggui serve` default; `wss://` once TLS fronts it) — you don't configure it host-side. Wire format: [WebSocket Protocol](/api/websocket-protocol/).\n\n### Actions feel \"dropped\" during a flaky connection\n\nThe iframe-runtime buffers outbound actions while the socket is reconnecting and flushes them on resume — actions aren't lost across a transient drop. A generated component can disable destructive submits while offline, but that's component behavior inside the iframe, not host wiring.\n\n→ The host surfaces connection trouble via the `ggui:observe` channel (`subscribe-failed`). See [Error Handling → renderer-side faults](/cookbook/error-handling/#renderer-side-faults-stay-inside-the-iframe).\n\n### `CONTRACT_VIOLATION` error frame on the live channel\n\nAn inbound action whose name is undeclared, or whose payload fails the contract's `actionSpec[name].schema`, is rejected with a typed `CONTRACT_VIOLATION` error frame on the live channel — nothing reaches the consume buffer. The same dispatch sent through the relay (`ggui_runtime_submit_action`) is answered as a normal tool result, `{ ok: false, code: 'CONTRACT_VIOLATION', message, violations }` (not an `isError` result), and never reaches `ggui_consume`. `ggui_render` / `ggui_emit` validation failures instead reject the agent's own tool call. (The earlier `_ggui:contract-error` channel + `ContractErrorPayload` shape were removed in draft-2026-06-11.) See [MCP Protocol → Error Codes](/api/mcp-protocol/#error-codes).\n\n---\n\n## Renders\n\n### Session not found (`-32002`)\n\nA render has been reaped (TTL elapsed, or server restart). Re-run the in-flight intent through `ggui_handshake` + `ggui_render`; `ggui_consume` returns whatever events were collected before the render expired (`status: 'expired'`).\n\n→ Lifecycle: [`ggui_handshake` / `ggui_render`](/api/mcp-protocol/). Recovery pattern: [Error Handling → Recover from an expired render](/cookbook/error-handling/#recover-from-an-expired-render--32002).\n\n### Cross-environment render mismatch\n\nSandbox and production renders live on separate `appId`s. If you're moving between environments, regenerate the `appId` + key pair — renders never migrate.\n\n---\n\n## Component rendering\n\n### `Module does not export a default function component`\n\nThe generated bundle is malformed. Causes, in order of likelihood:\n\n1. A partial or failed generation was served (the server's generation pipeline normally repairs these before delivery).\n2. A custom `gadget` returned a renderer that does not export `default`.\n3. Generation logs (visible in the [Console](/hosted/console/) or your operator dashboard) show an esbuild error.\n\n### Component shows the fallback border\n\nA fault in generated component code is isolated to its sandboxed iframe — it can't crash your host tree. The host's `<AppRenderer onError>` fires for iframe/transport faults; structured failures (contract errors, subscribe failures) arrive on the `ggui:observe` channel.\n\n```tsx\n<AppRenderer\n  toolName=\"ggui_render\"\n  sandbox={sandbox}\n  html={html}\n  onError={(err) => console.warn(\"render error\", err)}\n/>\n```\n\nRegeneration of broken component code happens server-side in the generation pipeline; the iframe re-mounts with corrected HTML on the next `resources/read`. See [Error Handling → renderer-side faults](/cookbook/error-handling/#renderer-side-faults-stay-inside-the-iframe).\n\n### Renderer still shows old code after I redeployed\n\nThe browser-side module cache keys on `contractHash`. A redeploy that does not bump the hash will not invalidate the cached factory. Either:\n\n- Trigger a new generation by changing the agent prompt or the `actionSpec`, which advances the hash; or\n- Hard-reload the iframe (`<AppRenderer>` re-mount).\n\n---\n\n## Gadgets and tools\n\n### `[gadget] export \"X\" from package \"Y\" is not loaded`\n\nThe generated component called a gadget export that the operator never registered on `App.gadgets`. Add the package (or the specific export) to `ggui.json#app.gadgets` — see [SDK → Gadgets → Operator registration](/sdk/gadgets/#operator-registration).\n\n### `gadget_not_registered` / `gadget_package_mismatch` / `gadget_public_env_missing`\n\nRender-time validation rejections, not runtime errors: `gadget_not_registered` means the contract references an export the operator hasn't registered; `gadget_package_mismatch` means the referenced export belongs to a different registered package than the one keyed; `gadget_public_env_missing` means a wrapper's `requires` aren't satisfied by `App.publicEnv`. All three name the offending key so the operator fix is unambiguous — see [SDK → Gadgets](/sdk/gadgets/).\n\n---\n\n## HTTP status codes\n\nThe MCP transport surfaces transport-level failures as HTTP status codes on the `/mcp` endpoint. Read the status first. A refusal may also carry a JSON-RPC `error` body whose `code` comes from the table below — every `401` carries `-32007`, and on hosted GGUI a `503` for a saturated generation queue carries `-32014` and a `403` for a deprovisioned app carries `-32003` with a `data.refusal` you can act on — so either signal identifies it. An unmapped server failure is a `500` whose body carries `-32603`.\n\n| Status | Meaning                                                                                                                                                                                                                                          |\n| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `401`  | Missing or invalid `Authorization` header — see [Authentication](#authentication)                                                                                                                                                                |\n| `403`  | Not authorised for this `appId` or method. A bare `403` carries `-32007` and says no more by design; a **deprovisioned app** answers `403` with `-32003` and `data.refusal` — see [JSON-RPC error codes](#json-rpc-error-codes)                  |\n| `404`  | Unknown route, or `appId` does not exist on this environment                                                                                                                                                                                     |\n| `429`  | Rate limited at the ingress — hosted GGUI's per-IP flood backstop, plain HTTP with no JSON-RPC body; honor `Retry-After` (seconds). A render-rate cap never arrives this way: it is an in-band refusal — see [Rate limits](/hosted/rate-limits/) |\n| `5xx`  | Server-side failure. A `503` with `Retry-After` (body `-32014`) is hosted GGUI's saturated generation queue — wait that long; otherwise retry with jitter, then file a support ticket with the response `x-request-id`                           |\n\nA render-rate cap never surfaces as HTTP at all. On `tools/call` a limited `ggui_render` resolves as a refusal — `isError: true`, `structuredContent.outcome: 'refused'`, `refusal.code` `app_rate_limited` (or `issuer_rate_limited` for the issuing identity's cap), `retry: 'later'`, the wait in `message` and `fix`, handshake intact — so wait and retry the same call with the same `handshakeId`; see [Rate limits](/hosted/rate-limits/). The only `429` hosted GGUI sends is the ingress's per-IP backstop, plain HTTP with `Retry-After` and no JSON-RPC body; no first-party server emits `-32013`. Honor `Retry-After` wherever the server sends one — hosted GGUI's `503` for a saturated generation queue always does, and its body carries `-32014` `GENERATION_OVERLOADED`.\n\n## JSON-RPC error codes\n\nSuccessful HTTP responses (`200`) may still carry a JSON-RPC `error` object with a numeric `code`. A server may add a `data` field with a `requestId` you can quote when filing support. Authoritative reference: [MCP Protocol → Error Codes](/api/mcp-protocol/#error-codes).\n\n| Code     | Name                  | Meaning                                                                                                                                           |\n| -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `-32700` | Parse error           | Malformed JSON in the request body                                                                                                                |\n| `-32600` | Invalid request       | Required JSON-RPC fields missing (`jsonrpc`, `method`)                                                                                            |\n| `-32601` | Method not found      | Unknown method or tool name                                                                                                                       |\n| `-32602` | Invalid params        | Wrong types or missing arguments                                                                                                                  |\n| `-32603` | Internal error        | Server-side failure                                                                                                                               |\n| `-32007` | Unauthorized          | Missing, invalid or expired bearer, or a handler that needs a signed-in user — rides HTTP `401`; the same code is in the response's JSON-RPC body |\n| `-32002` | Session not found     | Session expired or never existed                                                                                                                  |\n| `-32003` | App not found         | The endpoint no longer serves this app; for a deprovisioned app the body carries `data.refusal`                                                   |\n| `-32006` | Mount unavailable     | A `resources/read` found the render locator but can return no mount for it                                                                        |\n| `-32014` | Generation overloaded | Platform extension: the generation queue is saturated — HTTP `503` with `Retry-After` governs the retry; the server, not the caller, is the limit |\n\n`-32004` is retired-reserved. Generation failure never surfaces as a\nJSON-RPC error: `ggui_render` returns a `tools/call` **result** with\n`isError: true` and `structuredContent.error: { code, message }` — codes\n`PRODUCTION_FAILED | VALIDATION_ERROR | NO_PLATFORM_KEY |\nNO_CREDENTIALS | GENERATION_QUEUE_OVERLOADED`. Match on `error.code`, not on a numeric JSON-RPC code.\nA failed render carries no `resourceUri` and no `_meta` — do not attempt\nto mount it.\n\n`-32006` answers a `resources/read` of a render locator that cannot come\nback as a mount. Match on `error.data.code` for the reason —\n`NOT_FOUND` (nothing resolves it, or you may not read it),\n`BLUEPRINT_UNRESOLVABLE` (the component behind it is gone),\n`NOT_SUPPORTED` (this deployment keeps no durable record, so an evicted\nrender can never be restored here) or `NOT_MOUNTABLE` (it resolved, but\nnothing can deliver it). `NOT_FOUND` arrives on `-32002` rather than\n`-32006`. None of the four is worth retrying — ask the agent for a fresh\nrender instead.\n\n`-32003` on a `403` is the endpoint-level refusal: the app the per-app endpoint serves has been deprovisioned, and every JSON-RPC request from a caller bound to that app — `initialize` included — is refused before any tool runs. The body carries `data.refusal` `{ code, message, fix, retry, appId }`: `code` is `app_deprovisioned`, `retry` is `never`, `fix` is the tenant-side repair (re-provision the app), and `appId` is the app the refused endpoint serves — the same id as the endpoint path — so key your repair loop on it instead of parsing the message. Retry per `data.refusal.retry` — today `never`: nothing on the caller's side changes the answer. It is the app's state, not yours: a revoked subject on a deprovisioned app gets this answer too, before its own deny status is read, and learns only an id it already holds. An anonymous request never sees it — it is refused `401` by the auth adapter first — and a caller bound to a different app gets the bare `403` before any lookup. A `403` with `-32007` and no `data` is a different thing — an authorization failure the server declines to explain (no issuer, a foreign issuer, a denied subject on a live app, a key bound elsewhere or not the owner's); check the credential and the `appId` in the URL.\n\n### Reading errors from Claude Agent SDK\n\nWhen you drive GGUI from the Claude Agent SDK, JSON-RPC errors arrive inside the `SDKMessage` stream rather than as thrown exceptions — tool results land inside `user` messages as `tool_result` content blocks. Walk the stream and match on `is_error`:\n\n```typescript\nimport { query } from \"@anthropic-ai/claude-agent-sdk\";\n\nfor await (const message of query({ prompt, options })) {\n  if (message.type === \"user\") {\n    for (const block of message.message.content) {\n      if (block.type === \"tool_result\" && block.is_error) {\n        // block.content holds the JSON-RPC error.message string\n        console.error(\"tool failed:\", block.tool_use_id, block.content);\n      }\n    }\n  }\n}\n```\n\nHost SDKs (`@anthropic-ai/claude-agent-sdk`, `@modelcontextprotocol/sdk`, `openai`) each define their own error types — consult their docs for the thrown shape. GGUI guarantees the _wire_ error (HTTP status + JSON-RPC code), not the host-SDK's exception class.\n\n### Generation timeout\n\nBump your transport's timeout or shorten the agent prompt. Most timeouts are constraint-misalignment in the prompt, not raw slowness. With the `@modelcontextprotocol/sdk` client:\n\n```typescript\nawait client.callTool({ name: \"ggui_handshake\", arguments }, undefined, {\n  timeout: 60_000, // 60 s\n});\n```\n\nIf you control the operator side, check [Blueprint-First Architecture](/architecture/ui-generator/) — a matched blueprint short-circuits generation entirely.\n\n### First render feels slow (no error, just wait)\n\nThis is the blueprint-cache miss path, not a bug. The two-stage flow is: a cheap LLM match against cached blueprints → full generation if no match. A cold generation takes tens of seconds. Symptoms:\n\n- A cache hit surfaces as `suggestion.origin === 'cache'` on the handshake; the paired `ggui_render` then returns `cache: {hit: true, llmCallsAvoided, ...}`. A miss shows `origin === 'agent'` / `'synth'` and `cache.hit: false`.\n- Subsequent calls with the same `actionSpec` should hit cache (`cache.hit: true`) and avoid regeneration cost.\n\nIf repeated identical prompts never warm the cache, the prompt or `actionSpec` is varying between calls in a way that advances `contractHash`. Stabilise the inputs or pre-register a blueprint against the operator endpoint. See [Blueprint-First Architecture](/architecture/ui-generator/).\n\n---\n\n## TypeScript build\n\n### `tsc` under `moduleResolution: NodeNext` reports errors inside `node_modules/@modelcontextprotocol/ext-apps`\n\nYour own code is fine; the errors sit in a dependency's declarations. With `moduleResolution: \"NodeNext\"` (or `Node16`), `tsc` reports TS2834 and TS2339 inside `node_modules/@modelcontextprotocol/ext-apps/dist/src/app.d.ts`, and, before #847, TS2305 on `@ggui-ai/protocol/dist/types/host-context.d.ts` for the names it then re-exported from ext-apps.\n\n**Cause.** `@modelcontextprotocol/ext-apps` 1.7.5 ships extensionless relative specifiers (`./types`, `./events`, `./standard-schema`) in its own `.d.ts` files — a defect in the published package, tracked upstream at [modelcontextprotocol/ext-apps#704](https://github.com/modelcontextprotocol/ext-apps/issues/704). `@ggui-ai/mcp-server` and `@ggui-ai/iframe-runtime` depend on ext-apps, so the defect surfaces in a program that includes them (`@ggui-ai/protocol` alone no longer references ext-apps, #847).\n\n**Scope.** This reaches your program only through a package that depends on ext-apps 1.x — `@ggui-ai/mcp-server`, `@ggui-ai/iframe-runtime`, `@ggui-ai/mcp-apps-react-native`. `@ggui-ai/protocol` alone no longer pulls ext-apps in (#847): its declarations reference nothing from it, so a NodeNext consumer of the protocol package typechecks clean without the switch.\n\n**Fix.** Until ext-apps ships the upstream fix, set the TypeScript switch for third-party declaration defects in the consumer's `tsconfig.json`:\n\n```json\n{\n  \"compilerOptions\": {\n    \"skipLibCheck\": true\n  }\n}\n```\n\n`skipLibCheck` skips type-checking of `.d.ts` files only — your own sources still typecheck fully. The `@ggui-ai/protocol` dependency bump follows the day the upstream fix is published; nothing in your code changes then except removing the switch if you want it gone.\n\n## Debugging tips\n\n- **Network tab** — inspect the WebSocket frames at `/ws` (self-hosted: `ws://127.0.0.1:6781/ws`; hosted cloud: `wss://mcp.ggui.ai/ws`) and the JSON-RPC bodies on `/mcp`.\n- **MCP Apps host postMessage** — for non-WebSocket hosts (Claude Desktop, generic MCP clients), props updates arrive as a `ui/notifications/tool-result` postMessage carrying the same `ai.ggui/render` slice (`propsJson`) the WS path projects — one projection, two envelopes.\n- **Console app** — [`@ggui-ai/console`](/hosted/console/) gives you a live session inspector with raw envelopes, generation logs, and a contract diff viewer.\n- **Conformance kit** — if you're building your own client or server, run `ggui conformance` against your endpoint; protocol-level mismatches show up as named violations, not stack traces. See [Conformance](/protocol/conformance/)."}]}