Plane docs
Concepts

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. display and attributes are 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.
  • scope is a free string matching ^[a-z][a-z0-9_]{0,31}$, not an enum. user, internal_user, system, service are conventions; plane_* is reserved. The closed set you actually want is per agent, and that is identity.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.

On this page