The assistant’s navigate tool needs the list of pages the app can open: paths, titles, descriptions, and the params and search each one takes, with their choices. That’s tens of kilobytes. Sending it with every message wastes the request. Fetching it once per session goes stale after a deploy. Keeping it on the server means the server owns the client’s routes.

So the list is content-addressed. The client sends its hash, and the full list only when the server asks.

// client: the same canonical form the server hashes
const hash = "sha256:" + sha256(canonicalJson(targets));   // sorted by type, then name; keys sorted at every depth

POST /assistant { catalog: { hash }, view, actions, messages }
// server
async function resolveCatalog(accountId: string, { hash, targets }: CatalogRef) {
  const key = `catalog:${accountId}:${hash}`;
  if (targets) {
    if (hashOf(targets) !== hash) return { ok: false, status: 400, code: "catalog-hash-mismatch" };
    await cache.set(key, targets, ONE_DAY);
    return { ok: true, targets };
  }
  const cached = await cache.get(key);
  return cached ? { ok: true, targets: cached } : { ok: false, status: 409, code: "catalog-required" };
}
  client                                        server
  ─── { catalog: { hash } } ──────────────────▶ hit: run the turn
                                                miss
  ◀────────────────────────── 409 catalog-required
  ─── { catalog: { hash, targets } } ─────────▶ verify, store, run the turn

Rules

  • One canonical form. Both sides sort by plain code-unit comparison, never locale compare, which differs between runtimes. One stray ordering difference and every request misses.
  • The server verifies. A hash that doesn’t match the targets sent with it is a 400, so no client can store a list under someone else’s hash.
  • Keyed per account. The hash alone would dedupe across accounts, but the list says what one account can open. It shouldn’t be readable from another.
  • Resend once. A second catalog-required on the same request is an error, not another retry.
  • Only the slow part. The catalog is routes, which change on deploy. What’s on screen and the page’s live actions change every message, so they ride in full.

Why it works

The steady state is a 71-character string. A deploy that changes any route changes the hash, so a turn never runs against a stale page list, and the first request after a deploy pays one extra round trip.

How this fits the larger system: The UI is the engine.