Skip to content
Sneak peek — you found hosted ggui early · official launch soon

Rehydration

read as .md

Scroll 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.

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.

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 than userId, 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 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.
  • 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.

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/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.
  • 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 createdAt plus 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.

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.

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 replaces NOT_FOUND wholesale 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. The detail field 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.