---
title: Rehydration
description: Why an old ggui card in chat history still works — how a resources/read on an evicted locator re-mints a live session with the same identity and a fresh channel token.
---

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.

## 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

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.

## 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/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](/hosted/trust/#deletion--retention).

## 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

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](/protocol/conformance/), 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.