Cereal docs
Sessions and UI

Metadata and filtering

Attach application context to sessions and messages, then filter sessions by it.

Every session carries metadata: a Record<string, Json>, always present ({} by default), capped at 64 KB encoded, and never interpreted by Cereal. A tenant, an account id, a locale, a research brief ID.

Use it for application context such as an account ID, locale, or ticket number. Tool handlers receive it in verified session context. Use identity for the user or system the session acts for.

const session = await client.sessions.create({
  agentId,
  initialMessage: "What are the launch risks? Cite your sources.",
  metadata: { customerId: "cus_42", locale: "en-GB" }
})

// Shallow merge at the top level; `null` deletes a key.
await client.sessions.updateMetadata({
  id: session.id,
  mode: "merge",
  metadata: { locale: "fr-FR", briefId: null }
})

// `replace` swaps the whole document.
await client.sessions.updateMetadata({ id, mode: "replace", metadata: { locale: "de" } })

Every change is an event (session.metadata) carrying the change as requested: the patch for a merge, the whole document for a replace. Folding those reproduces the row, which is what keeps the log the source of truth. The 64 KB cap is checked on the result, and exceeding it is MetadataTooLarge.

Filtering

await client.sessions.list({ filter: { metadata: { customerId: "cus_42" } } })

Filtering is containment and nothing else: every key of the filter is present on the row and its value is deep-equal. Extra keys on the row are ignored. Filters are capped at 20 keys and 4 levels; anything else is MetadataFilterInvalid.

Filters require exact values; they do not support ranges or partial nested matches. Put fields you need to query at the top level of session metadata.

Message metadata

sessions.send({ metadata }) attaches JSON to one message rather than to the session: which Slack message, which email thread, which client request id produced this turn.

await client.sessions.send({
  id,
  message: "and also…",
  metadata: { slackTs: "1725339000.001" }
})

Message metadata differs from session metadata:

  • It never reaches the model. Use it for application identifiers and annotations.
  • It is not queryable. Session metadata is the queryable surface; message metadata is the record. The answer to "find the session for brief 88" is to put the brief ID in session metadata.
  • Annotating is an append, never a mutation. An agent.message is what the model said and what a resume replays, so nothing rewrites one.
await client.sessions.annotate({
  id: session.id,
  seq: 12,                         // a user.message or an agent.message
  metadata: { sentiment: "positive" },
  mode: "merge"
})

Where it goes

Session metadata leaves Cereal in the body, never in the token. Every MCP request for a session carries params._meta.cereal = { sessionId, agentId, agentVersion, metadata, identity }, and a hook envelope's session object carries it too. It is deliberately absent from the JWS claims: it is caller-owned and up to 64 KB, and tokens end up in proxy logs. body_sha256 already makes the body tamper-evident, which is what lets a handler trust ctx.session.metadata.

An update takes effect on the next tool call, mid-turn included.

On this page