Identity
Who a session acts for. Plane stores it verbatim and owns no users.
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.
Plane does not own users. You assert an identity and Plane stores it: there is no user table, no provisioning call, and no id Plane mints on your behalf.
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." }
})Two rules carry the design.
- Only
(scope, id)identifies.displayandattributesare decoration — stored as last-seen, delivered to hooks and to tool handlers, and ignored by grant lookup and by the switch comparison. Without that rule, an app rendering a display name from a stale cache would fork one user's grants in half. scopeis a free string matching^[a-z][a-z0-9_]{0,31}$, not an enum.user,internal_user,system,serviceare conventions;plane_*is reserved. The closed set you actually want is per agent, and that isidentity.scopesAllowed.
Omit it and Plane assigns anonymous:<sessionId> — a real identity that can hold
grants, but a per-session one, so an unattributed session never shares an
authorization with anything else.
The agent's policy
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, and the default is the point
scopesAllowed: ["user"] // at most 16
}
}switchable: false means a sessions.send may not change whom the session acts for.
A session has a transcript, a resolved tool set and a warm prompt cache; changing
whose data it reaches into halfway through is a capability, not a convenience. Even
with it on, a switch is refused for a sub-agent child — a child that could pick its
own principal would be a privilege-escalation primitive.
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 Plane makes for a session carries
params._meta.plane = { sessionId, agentId, agentVersion, metadata, identity }, and
@plane/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
Plane'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 Plane 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 and
attributes is unbounded JSON, and a token ends up in proxy logs.
Per-user OAuth
A grant is keyed (connection, identity, resource) — no agent id, 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.