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.