Attempt first, explain after
After a render with actions, GGUI tells the agent it may wait up to 25 seconds for the user’s tap. That wait is the one thing about taps that GGUI decides in advance for a particular kind of host. Everything else about taps it tries first and explains afterwards.
A tap on a card that an agent rendered into a chat needs two things from the host before the agent sees it. The host relays the card’s tool call, which puts the tap on the server. Then, if the agent has stopped listening, the host turns a message from the card into a new agent turn, which wakes the agent to go and read it. MCP Apps lets a host describe itself when the card connects, and GGUI’s runtime reads that description and then lets none of it hold a tap back.
This post is about why the runtime does not let the description decide what to try, what it uses the description for instead, and why the one decision made in advance rests on something else.
What a host can say about itself
The host answers the card’s ui/initialize request with a set of
host
capabilities,
which the specification says “describe the features and capabilities
that the Host supports.” Two of them describe whether a tap can reach
an agent. The released version of the specification has one: “Host
can proxy tool calls to the MCP
server.”
The current draft adds the other, “Host supports receiving content
messages (ui/message) from the
view,”
which the released capability
list
does not include.
ui/message matters more than it looks. It is the only method a card
can call on its host that can start an agent
turn.
Once the agent has stopped waiting, a host that does not take it cannot
be woken by the card, and the user has to type something before the
agent sees the tap.
The runtime records what the host said the moment the connection resolves, and the comment on that line states the policy: the record is there to explain failures, and no attempt waits on it.
Silence is not a refusal
The reason is written into the module that keeps the record. Absence of a capability “MUST NOT stop the runtime from attempting the operation,” because hosts under-advertise. GGUI’s own embed host once relayed every tool call while advertising none of that ability, and older copies of it still do. A runtime that read silence as “cannot” would have turned off working paths in its own product first.
A host can also be honest about what it does and still lose a signal on the way back. When a tap lands, the server says whether an agent is already waiting on that card. A host that normalizes tool results can drop that field, and the runtime’s comment names one that does: claude.ai strips it. So the runtime treats “not confirmed” the same as “no,” and wakes the agent rather than waiting for a confirmation that will never arrive. Being wrong that way has a price: an extra agent turn that ends in one empty read, and a notice telling the user the agent was not listening when it was.
Both cases lead to the same rule for taps. No verdict about the host holds a tap back, even after the runtime has decided the host cannot relay one, and its source says why: “the attempt is the self-heal sensor.” A tap is the only reading that can show a host works now, including a host that did not work a minute ago. The live channel’s background polls are treated differently. While the verdict stands, they fail at once without a round trip, so within one mount only a user’s tap can clear it.
Why a blind attempt is cheap
Waking the agent whenever the runtime cannot confirm it is listening would be reckless if an extra message could do harm. Two properties of the message the card sends keep it cheap.
The first is that the message carries a pointer to the card and leaves the tap on the server. By the time the card wakes the agent, the tap is already stored on the server, on a queue kept for that card. The message’s text carries the card’s session id and an instruction to go and read that queue, and it says so in as many words: “The gesture is queued on the consume pipe for that render — it is NOT in this message.” Structured metadata rides on the same text block for hosts that want it. It adds the action’s name, id and time, never its data, and nothing depends on it. The queue hands each tap out once, so a message the agent did not need costs a read that comes back empty and adds no second copy of the tap. The comment on that function names the alternative it avoids: a message carrying the tap itself would let the agent act on the copy in the message and then again on the copy in the queue.
That is a property of the message, and it is not the whole defence.
The runtime separately puts the whole tap, data included, into the host’s model context on every tap. The specification lets a host hold that context until the next user message, so on such a host the turn the message starts can carry both the pointer and the full tap. What stops a double action there is an instruction: ggui_consume tells the
agent
to treat each action’s id as an idempotency key and apply a
side-effecting action at most once.
The second property is that the instruction lives in the message’s
text.
The hosts that comment names, claude.ai, chatgpt.com and GGUI-aware
SDKs, pass the text of a ui/message to the model as it is, and none
of them runs anything of GGUI’s to interpret it. The specification asks a host only to add the message to the conversation and says nothing about metadata attached to it, so the runtime puts the whole instruction into the message’s text and none of it into the metadata. The text names the card and the tool to call and leaves out
what the user did.
A wasted message therefore costs an extra turn, one empty read and,
on some hosts, a consent prompt or a notice that says the wrong thing.
A missing one can cost a tap the agent never sees. Given that trade, the runtime wakes the agent whenever it cannot confirm that the agent is listening, on every host. For a host builder, that message arrives as a user-role ui/message whose text is an instruction addressed to the model. The host’s own policies on consent, display and card-written messages decide whether it becomes a turn, and a host that filters card-written instructions drops the wake-up.
What the advertisement is for
What the host said still gets used, only later and for a different job, which is to explain what already happened.
When the tap reaches the server and no agent is confirmed to be waiting, the card sends the host a message to start a new turn whether or not the host advertised that it accepts messages. The advertisement decides only the wording of the notice the user sees. A host that said it accepts messages gets “sent to chat.” A host that did not gets “Send a message to continue,” so the user knows the tap is waiting for them.
When the tap cannot reach the server at all, the runtime has to decide whether this is a passing failure or a host that will fail every time, because one persistent explanation beats an identical error on every click. It commits to “this host cannot” only after an attempt has failed, never on silence alone. Here is the condition, with its comments removed:
if (
hostCapabilitiesCaptured() &&
((!hostCanRelayToolCalls() && isRelayShapedFailure(resp)) ||
confirmedRefusal)
) {
announceRelayIncapability(
confirmedRefusal ? 'confirmed-refusal' : 'advert-silent',
{ sessionId, appId },
);
return;
}
That is lines 3843 to 3859 of the runtime. This condition gives two ways to earn the verdict, and both need the connection to have finished, so an empty record means the host said nothing rather than that nobody has asked yet. In the first, the host said nothing about relaying tool calls, and this attempt came back with no well-formed result at all. A result that says the card’s session has expired does not count, because a host that carried the error back has proved it can relay. In the second, the host refused with JSON-RPC’s method-not-found error, the one code the runtime counts as a confirmed refusal, and that outranks anything it advertised. Either way, the verdict stands only until the next well-formed result arrives, which clears it. A third path sits outside this condition. When the host refuses the handshake itself, the same verdict is set before any tap, and only the card’s next boot clears it. The changelog entry that shipped the first way puts it in one line: a host is never assumed broken just because it has not advertised a capability yet.
While the verdict stands, taps keep going out, and each one that
fails is counted for the
host
as a dead tap. The count is kept against the standing verdict, never
against what the host advertised, so a host that advertised the
ability and then refused has its failed taps counted the same way as
one that said nothing. The count reaches a host as a relay-dead-tap event on GGUI’s own ggui:observe message, outside the MCP Apps protocol, so a host sees it only if it listens for that message. One that does gets a number for how often its users hit this, where it would otherwise have only a complaint.
The one decision made in advance
There is one place where GGUI does have to decide before anything fails, and it concerns the agent rather than the card.
After a render whose contract has actions, the render result carries a hint that tells the agent what to do next. In the result the agent reads, it looks like this:
"nextStep": {
"tool": "ggui_consume",
"description": "The UI has interactive actions. After this turn's other tool calls, you may long-poll once (waits up to 25s) to catch an immediate gesture; if `events` comes back empty, end your turn — later gestures arrive as new user messages carrying their own consume directive.",
"example": "ggui_consume({ sessionId: \"render_abc123\", timeout: 25 })",
"args": { "sessionId": "render_abc123", "timeout": 25 }
}
The timeout rides inside the hint for a
reason.
ggui_consume on its own does one read and returns at once, so an
agent that copied an example without a timeout got an empty answer
immediately and ended its turn, which is how the first live claude.ai
test failed. Twenty-five seconds is the most the server allows for one
call.
The wait catches a tap that comes right after the card paints, which matters on a host that cannot start a new turn from the card. On a host that turns every message from the card into a later agent turn, the wait buys nothing, and it costs the user up to 25 seconds of an agent that looks busy.
The advertisement cannot tell those hosts apart. The draft’s message
capability says the host receives the message, and the host behaviour
the specification asks
for
is that it “SHOULD add the message to the conversation context” and
“MAY request user consent.” Elsewhere the specification’s prose
describes ui/message as something that “also trigger[s]
follow-ups”,
and its lifecycle
diagram
has the host “Process message and follow up.” Neither is a requirement,
and neither says when the follow-up happens. A host can honour the
advertisement fully and still leave a tap sitting in the conversation
until the user types.
So instead of inferring the answer, version 0.23.0 adds a header the host’s code sets on its requests,
Ggui-Host-Capabilities: ui-message-turn, and the promise it carries
is specific. The host delivers every ui/message the card posts as a
later agent turn: at once when no turn is running, after the live turn
ends when one is, and never by cancelling it. Otherwise it answers the card with an error. A host sets the header per request, and only for a client that behaves this way. The server reads the header on every
request,
and when the host has declared it, the hint is left
out,
so the agent ends its turn when the card paints. The header governs only that first wait. After the agent reacts to a tap, ggui_consume’s own description still tells it to poll again, on every host, and the header does not reach that loop yet. A server ignores a
token it does not know, and a transport without headers carries no
declaration, so in both cases nothing changes. The release
notes say the
same.
The file holds the header to most of the bar our post on
contracts sets. It
names the parties: the host’s code sets the header, the server reads
it, and the agent follows the hints it is given. It states what the
host owes, and how a host that cannot keep the promise must fail,
which is by answering the card with an error. It names the breach, a
ui/message that neither produces a later turn nor comes back as an
error. What is missing is anything that watches for that breach, so it is defined but not yet detected. The card does not read the host’s reply to its message either, so even the error a host must send has nobody listening for it yet. The file is also explicit about why
the model never sets the header: “a flag the model can write would be
the agent writing behaviour into the contract, and could not be
verified.”
The agent never has to know
None of this reaches the model as a question. The render tool’s own description tells the agent to follow the hint when there is one, and to end its turn when there is none, either because the card has no actions or because this host delivers taps as new messages. The agent never reads a capability table, and whoever builds it does not have to write host-specific instructions about taps into its prompt. To know how a tap comes back, the agent only has to follow the hint.
What a host can do lives in code at both ends: the card’s runtime, which attempts and then explains, and the server, which shapes the hint from a declaration the host made. The agent describes what it wants, and the promises about hosts come from the parties that can make them.
What this does not do yet
No host in the open-source repository sends the header yet. At the commit this post links, it is defined in the protocol package and read by the server and its render handler, and the only code that sets it is the server’s own tests. None of the repository’s sample hosts or host helpers sends it. A host has to set it in its own code, and we have not surveyed which hosts outside the repository do. Where none is set, the agent is still told it may wait.
The conformance kit grades part of this. For tool calls it checks both
directions: one check fails a helper that advertises relaying and then
does not answer,
and another fails one that relays without advertising
it.
It never probes ui/message, it does not grade the header’s promise,
and it grades only the helpers whose makers run it. A host the runtime
meets in the field may never have been graded at all, which is why the
runtime cannot trust silence.
The notice can be wrong in both directions. Its wording follows the advertisement, and the runtime never reads the host’s reply to the message. So a host that accepts messages without saying so shows “Send a message to continue” even though the message went through, and a host that advertises messages but rejects this one still shows “sent to chat.” The verdict can overstate too. On a host that relays but does not say so, one transport error or timeout is enough to show “This host cannot relay actions to the agent,” and the verdict stands until a later tap gets a result, even if the user closes the notice. In the two notice cases the tap is already on the queue and only the sentence is wrong. In the third, the tap that hit the error never reached the server, and the user has to tap again.
We have watched this on claude.ai, where the runtime’s comments record the live tests. Other hosts that implement MCP Apps should behave the same way, since the runtime applies the same rules in every host. We have not watched them do it.
Attempt first, and explain after. The advertisement explains, the tap decides, and the one thing decided in advance rests on a promise the host’s code makes, with a named breach that nothing watches for yet.