Send the catalog by hash
Every page the client can open rides each request as a hash. The server caches the list per account; on a miss it answers 409 and the client resends it, once.
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-requiredon 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.