Rehydration
read as.mdScroll 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.
ggui 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.
What a read does
Section titled “What a read does”A ggui render is addressed by an MCP-Apps resource URI:
ui://ggui/render/<sessionId>[/<blueprintKey>][#<epoch>]When 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:
| Order | Resolution |
|---|---|
| 1 | The render row itself, when it is still there. A row past its expiry is put back into service, not refused. |
| 2 | A re-mint from the durable identity record, when the row is gone. |
| 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. |
The 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.
The 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.
What persists, what is re-minted
Section titled “What persists, what is re-minted”Durable, written once and kept:
- The render row — for as long as its retention allows (see below).
- 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 thanuserId, because it is retained for as long as the render is addressable. - The blueprint row and its body — blueprints carry no TTL, and bodies are content-addressed in object storage so many renders share one.
Re-minted per read, never stored:
- The
wsTokenand itswsUrl, 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. - The code URL and its strict-CSP module twin.
- 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.
One 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.
The retention posture that makes it work
Section titled “The retention posture that makes it work”Rehydration is only worth building if the records are still around. On hosted ggui:
- The default render TTL is effectively indefinite — 100 years, so
resources/readon an old card finds the live row rather than falling through to the registry. One knob drives every lifecycle site: the wire-levelexpiresAt, the row’s TTL stamps, and the sliding extensions on heartbeat and consume. - A per-render TTL an agent sets is still honored. Bounded renders are purged by DynamoDB at that TTL plus a seven-day grace window.
- 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
createdAtplus that cap. It is unset by default, which is the right pairing with indefinite retention. - Deletion is by path, not by schedule. Account erasure removes identity records by primary key alongside the renders they name; deleting an app cascades through its renders, blueprints and identity rows; and an operator takedown purges a blueprint body by content hash and writes a tombstone — there is no un-takedown operation.
The full statement, including what survives an account deletion on purpose, is on Trust & security.
What the host has to do
Section titled “What the host has to do”Nothing 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.
Two details are worth getting right:
- Read the per-render locator, not the declaration. Mounting the declaration-level static shell yields no bootstrap slice and fails with
MISSING_META_GGUI_BOOTSTRAP. - 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.
Honest limits
Section titled “Honest limits”Rehydration is not resurrection from nothing. Every failure is a typed JSON-RPC error rather than a shell that never paints:
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.NOT_SUPPORTED(-32006) — the same two cases on a server that keeps no durable substrate, where an evicted locator can never come back. It replacesNOT_FOUNDwholesale on such a server, for the same anti-oracle reason. A server that binds the substrate never emits it.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. Thedetailfield names which link broke.NOT_MOUNTABLE(-32006) — something resolved but nothing mountable came of it: no delivery channel wired, or a generation that never committed a component.
And 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.
The behavior on this page is arbitrated by the conformance kit, 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.