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.displayandattributesare decoration: stored as last-seen, delivered to hooks and to tool handlers, and ignored by grant lookup and when checking an identity change. scopeis a free string matching^[a-z][a-z0-9_]{0,31}$, not an enum.user,internal_user,systemandserviceare conventions;cereal_*is reserved. The closed set you actually want is per agent, and that isidentity.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.