Cereal docs
Sessions and UI

Identity

Pass your authenticated user identity to sessions and tools.

Session metadata says what a session is about. An identity says who it acts for, and it is the key a per-user OAuth grant hangs off.

Your application authenticates its users and supplies their identities. Cereal stores the asserted identity with the session and uses it to select tool authorization grants. Your app must check who may read or write each session.

const session = await client.sessions.create({
  agentId,
  initialMessage: "what did I ship this week?",
  identity: { scope: "user", id: "u_412", display: "Ada Lovelace" }
})

// Asserting it again on every message is the natural client implementation and is
// fine: the same (scope, id) is a relabel, not a switch.
await client.sessions.send({
  id: session.id,
  message: "and last week?",
  identity: { scope: "user", id: "u_412", display: "Ada L." }
})

Identity fields have distinct roles.

  • Only (scope, id) identifies. display and attributes are decoration: stored as last-seen, delivered to hooks and to tool handlers, and ignored by grant lookup and when checking an identity change.
  • scope is a free string matching ^[a-z][a-z0-9_]{0,31}$, not an enum. user, internal_user, system and service are conventions; cereal_* is reserved. The closed set you actually want is per agent, and that is identity.scopesAllowed.

Omit it and Cereal assigns anonymous:<sessionId>. That is a real identity and can hold grants, but it is per session, so an unattributed session never shares an authorization with anything else.

The agent's policy

The policy is part of the versioned definition, so it is committed and reviewed in your repository, and a session pinned to version 3 keeps version 3's policy for life.

definition: {
  // …
  identity: {
    required: true,          // refuse sessions.create with no identity
    switchable: false,       // the default
    scopesAllowed: ["user"]  // at most 16
  }
}

switchable: false means a sessions.send may not change whom the session acts for. Identity changes are also refused for sub-agent children.

Refusals arrive as IdentityRequiredError, IdentityNotSwitchableError, IdentityScopeNotAllowedError and IdentityAttributesTooLargeError.

What a key may assert

An API key may be restricted in what identities it may claim.

await client.apiKeys.create({
  key: { name: "ci", scopes: ["read", "control"], identityScopes: ["system:*"] }
})

A call outside the patterns is IdentityScopeNotAllowedError. This is what stops a leaked key from acting as any user and spending any user's OAuth grants. An empty list means unrestricted.

In a tool handler

Every MCP request Cereal makes for a session carries params._meta.cereal = { sessionId, agentId, agentVersion, metadata, identity }, and @trycereal/sdk turns that into ctx.session.

tool({
  name: "list_my_issues",
  handler: async (_input, ctx) => {
    const identity = ctx.session?.identity
    if (identity === undefined) return { error: "no identity on this session" }
    return db.issuesFor(`${identity.scope}:${identity.id}`)
  }
})

You may trust it because it arrives in the request body, and the body is bound to Cereal's signature by body_sha256, which the SDK verified before your handler ran. That is not the same as trusting an argument the model supplied: _meta is written by Cereal from the session row, and the model never sees it and cannot influence it.

The JWS claims carry only { scope, id }. display is a person's name, attributes is unbounded JSON, and a token ends up in proxy logs.

Per-user OAuth

A grant is keyed (connection, identity, resource), with no agent id and no session id. That absence is the "authorize once, every agent that uses this connection can act" property, and its consequence is that the connection is the consent boundary. Two agents that need different scopes from one server need two connections. See per-user OAuth.

On this page