Withhold the material, keep the name

From August 16 into the 17th, for about a day, every claude.ai view of a hosted GGUI render sat at “Waiting for tool result…” and never moved. The browser test we wrote for the fix still carries the dates in its header. The generated component was fine. The agent was fine. The tool result had lost one field too many, the pointer to where the view lives, and a host that mounts views the way the MCP Apps spec describes had nothing left to mount from.

This post is about that pointer. It covers what a GGUI tool result leaves out on purpose, the change that took out one thing too many, and the door a view now reads through when all it has been handed is a name. The rule underneath is easy to state and was easy to break. A result may withhold the material a view needs to start, and the name of the view has to survive whatever the withhold removes.

A hosted result carries a name and leaves the material behind

When an agent calls ggui_render, two different kinds of thing could come back. One is identity, a locator of the form ui://ggui/render/<sessionId> that names the render and can be read later. The other is material, everything the view needs to boot: the session id, the runtime and live-channel URLs, a short-lived token for that channel, an expiry, the theme and an epoch.

Hosted GGUI returns the first and withholds the second. Our API reference puts it in one line, “the result publishes the durable identity and nothing else: no ai.ggui/render slice, no wsToken.” This is the whole of what a hosted result carries, as the reference prints it:

{
  "content": [{ "type": "text", "text": "Created render render_abc123" }],
  "structuredContent": {
    "sessionId": "render_abc123",
    "resourceUri": "ui://ggui/render/render_abc123/bp_9f2c"
    // … plus action / contractHash / blueprintId / variantKey / cache
  },
  "_meta": {
    "ui": { "resourceUri": "ui://ggui/render/render_abc123/bp_9f2c" },
    "ui/resourceUri": "ui://ggui/render/render_abc123/bp_9f2c"
  }
}

The same locator appears three times. The material appears nowhere.

The main reason is the token. A token that rode inside a tool result would sit in the chat transcript, readable by whatever can read the transcript, for as long as the transcript lives. The server’s own comment names that reason, no bootstrap material crossing a chat transcript, beside two others, thread-scoped ownership checks and fresh credentials on every read. The reference gives the token’s terms. A new token is minted on every call, and a leaked one is useless once its short TTL has passed. Keeping the material out of the result is what lets the name stay useful for a long time. A card opened six months later gets a token minted at the moment it is opened, from a read that fails closed without a request context, checks that the caller’s app matches the render’s and, for an end user, checks that the render is theirs. On a hosted ggui_render or ggui_update result, the material never enters the conversation at all.

The name is a locator that can still be read months later

A locator is a small grammar rather than an opaque string, and each part of it does a job. The reference gives the shape as ui://ggui/render/<sessionId>[/<blueprintKey>][#<epoch>]. The session id names the render. The optional blueprint key names the cached component behind it, so a read whose render record is gone can still fall back to the original card with its default props. The optional epoch pins one version of the card.

The epoch is what makes a name safe to hold. A render can be updated, and each update advances its epoch. A bare locator tracks the session’s current state, so a host that re-reads it gets the latest card. A pinned locator, which every ggui_update result carries, returns identical content forever, so an update’s card reopened later shows the version it announced. Both kinds are read the same way, through the MCP resources/read that the protocol already defines, and rehydration rides that same read. A card from months ago comes back through the same door as a card from a second ago.

All of this depends on one thing reaching the host intact, the locator itself.

What broke was the pointer, and the payload went with it

Withholding the material is a deployment posture, one setting on the server. The code that implements it describes what went wrong the first time, in its own comment. The first version stripped _meta from the result wholesale. The material lived in _meta, so it went, as intended. The pointer lived there too, under _meta.ui.resourceUri, and it went with it.

That pointer is how a spec-following host finds the view. With it, the host resolves the locator and mounts the per-render shell, a self-contained HTML document that carries that render’s material inline. Without it, the host falls back to the static shell the tool announced when the server listed its tools. That static shell had one place to look for the material, the tool result it was handed, and the result no longer carried any. “Waiting for tool result…” was literally true. The shell was waiting for material that was never coming.

The fix went out as three commits within 23 minutes on August 17, each public on the open-source repository. The first made the pointer survive the withhold, so the setting now removes the material and nothing else. The second gave the static shell a way to recover the material from the name alone. The third added a flag to ggui serve that turns the posture on for a self-hosted server, and the browser test that proves the second commit works.

The door asks the host rather than the network

The second commit is the one worth reading closely, because it decides who does the fetching. A view runs inside a sandboxed iframe. The obvious fix, having the iframe fetch the locator from the server itself, runs into the host’s content security policy, which may not let the frame talk to the server at all. So the view asks the host.

The runtime’s door sends the host a resources/read for the locator, and the host performs it with its own MCP client, the one it already uses to talk to the server. In the comment’s words there is “no fetch from this sandbox, so a host CSP without connect-src to the server is not in the way.” What comes back is the per-render shell. The material sits inside it on a single line, after a marker the writer and the reader share as one constant, and the reader takes the first marker only. From there the material goes through the same validation an inline slice would.

The shell takes the door only when it has a name and no material. The extractor reads the locator from structuredContent.resourceUri first and from _meta.ui.resourceUri second, and it accepts the ui://ggui/render/ prefix and nothing else. Its comment states the boundary exactly: “a result that carries the slice inline never needs this, and a result with neither is not a view.” Once a locator is found, the runtime stops waiting for a slice at once, because waiting would cost every spec-host view 30 s for nothing.

The browser test is the receipt. It runs a server with the withhold on, has a host mount the declaration-level static shell and forward the result, and in its browser half asserts two things. The view paints, and the host saw exactly one proxied resources/read. One read is the entire cost of the door.

What the door asks of a host

The door asks two things of a host, and both come from the MCP Apps spec rather than from GGUI. The spec requires a host to forward the tool result to the frame, as the ui/notifications/tool-result notification, which is how the frame learns it has a locator and no material. It also allows a host to relay a view’s resources/read, which here means performing the read with its own MCP client and handing back the contents. A host that does both mounts a GGUI view whichever shell it picked, the per-render one or the static one the tool declared. Our handshake reference describes the door from the host’s side, with the same browser test as its receipt.

Many hosts will never touch the door at all. A host that already resolves the locator on the result and mounts the per-render shell has the material before the view starts, so the frame has nothing to ask for. Our handshake reference describes a host that handles both postures as one that reads _meta.ui.resourceUri first and falls back to relaying a slice only when it actually found one on the result. The door exists for the other case, the host that mounts the static shell and forwards what it got, which is the case that went blank in August.

The two asks differ in weight, and in where the obligation comes from. Forwarding the result is a requirement of the MCP Apps spec, so a host that skips it is broken. Relaying the read is only a capability there, one a host advertises as serverResources. Our own contract goes further for exactly the hosts that take the door. The methods table in the handshake reference marks resources/read as conditional. A host that mounts the declaration-level shell against a server that withholds the material must answer it and should advertise that it can, and a host that mounts only per-render locators never receives it. A host that declines anyway leaves the frame having asked a question nobody answers. Since 0.22.0 the self-hosted shell gives each wait 30 seconds and then shows a failure card with a Retry button. A section further down covers what it did before.

Try the posture on your own server

A self-hosted server inlines the material by default, which means a host built against it can pass every test without ever taking the door. The quickest way to find out whether a host handles an identity-only result is to make the server send one:

npx -y @ggui-ai/cli serve --withhold-result-meta
# equivalently: GGUI_WITHHOLD_RESULT_META=1 npx -y @ggui-ai/cli serve

With the flag on, every render result carries the locator and nothing else, exactly as a hosted result does, and the view paints only if the host either mounts the per-render shell the locator names, or mounts the static shell, forwards the result and answers the read. The flag lives in the open-source CLI, and an embedder can set the same posture with the withholdResultMeta option on the server factory, which the API reference documents. Run a host against both postures before calling it done. Most of what went wrong in August would have shown up in the first run with the flag on.

One projection feeds both doors

A view can now get its material two ways. A self-hosted server inlines it on the result by default, and hosted GGUI hands it over through the read. If the two paths ever disagreed, the same render would look different depending on how its server was configured, and anyone who tested only one posture would never see the difference.

They come from one function. The module that builds them names deriveRenderMeta as its single entry point, the function every transport should route its render-derived fields through, and its comment calls the list of fields that turns a view into a slice the one place that list lives. That claim was not quite true. The per-render shell a host reads through the door took the view through the shared list and then forwarded a hand-written subset of its own. When a September fix added the contract’s action definitions to the shared list, they compiled cleanly and vanished on the door’s path while the inline result carried them. The shell now forwards the whole list, and a test that requires every member of the shared list to reach the shell caught exactly that field. What a host reads through the door and what a self-hosted result carries inline are now held equal by a test rather than by a comment.

Keeping the two paths equal took more than one function, and most of what the door grew into after August is that work. Two days after the fix, we closed a gap where a theme chosen by the agent or the operator silently vanished on the read path, because the per-render shell took its theme from server options alone. Theme resolution is now one resolver across every transport, so a card read through the door wears the same theme as one a self-hosted result carries inline.

What the door does not do yet

Two things are still open, and anyone building a host should know them.

The door is only partly graded. Since 24 September the conformance kit grades the server’s half. With the posture on, a result has to carry the name and none of the material, and one read of that name has to return a live mount. It grades the host’s half too, but only for a host helper that advertises it can proxy reads. A host that mounts the static shell without advertising that, and never answers the read, still passes. The handshake reference says such a host must answer, and the kit cannot see one that does not.

We have watched the door work in one place, the browser test above, where a host mounts the static shell and the paint arrives through exactly one proxied read. claude.ai views paint again under the withhold, but that paint cannot tell us whether claude.ai took the door or the restored pointer. Other hosts that implement MCP Apps should mount the same way, since the path is the spec’s own resources/read, but we have not watched them do it.

Two gaps that 0.22.0 closed

Through version 0.21, a host that never answered left the self-hosted default shell waiting without a limit. Its request helper carried no timeout, so the door’s “Resolving view…” lasted until an answer arrived, and “Waiting for tool result…” lasted until a result did. A failed door read was also reported as MALFORMED_BOOTSTRAP, a code that covers a malformed token too. From 0.22.0 both waits end after 30 seconds, the limit the hosted runtime already used, and a missed read reports the reason the hosted runtime reports.

Through the same release, the contract’s action definitions reached a view only over its live connection. A view that painted from the material alone, late or before that connection opened, held no action contract, so it could not enforce a one-shot action. From 0.22.0 the definitions ride in the material, and the runtime seeds its one-shot guard from them when no live frame has arrived. Hosted GGUI carries that fix. A self-hosted server has both from 0.22.0, and upgrading is the whole of the change.

Keep the name in a field of its own

The August failure took one wrong field to cause and three small commits to repair, and the lesson reaches past GGUI. A tool result is the one thing every host is guaranteed to receive. It is the wrong place for credentials and the right place for a name, and the two belong in separate fields, so that a change aimed at one cannot carry off the other. The server now withholds the material alone and leaves structuredContent, content and the pointer where every host can find them.