Environments, keys and scopes
The isolation unit, the credential that names it, and what a credential may do.
Organization → Environment. Two levels, and no third.
An organization is your company: users, memberships, and one set of signing keys. An environment is the isolation unit and the only scope any row carries. Agents, sessions, connections, providers, secrets, API keys, usage and spend caps all belong to exactly one environment.
await client.environments.create({
slug: "prod",
kind: "production", // "development" | "production" | "personal"
group: "customer-facing"
})
await client.environments.list()
await client.environments.archive({ slug: "old-staging" }) // keeps rows, refuses writesEnvironments are free and encouraged — dev and prod at a small company, one per
developer at a large one — and an environment slug is unique per organization, so
two organizations both having a prod is the ordinary case.
An id belonging to another environment answers 404, never 403. The row was not seen, so the API is not an existence oracle for a tenant you cannot reach.
API keys
A key is a credential and an environment. There is no environment option on the
client and there will not be one: PLANE_API_KEY=pl_prod_… plane sync writes to prod.
The slug inside the token is a hint for a human reading a .env file — Plane hashes
the whole string — but it is the hint that catches the mistake a silent success would
ship.
const { key, plaintext } = await client.apiKeys.create({
key: {
name: "ci",
scopes: ["read", "control"],
identityScopes: ["system:*"], // what identities this key may assert
expiresAt: "2027-01-01T00:00:00Z",
spend: { costUsdPerMonth: 200 }
}
})
// `plaintext` is returned once and never again.
await client.apiKeys.list()
await client.apiKeys.revoke({ id })
await client.me() // who this key is, and which environment it targetsScopes
read, data, control — with no implication between them. A control key
cannot list agents unless it also holds read.
| Scope | Roughly | Held by |
|---|---|---|
read | Lists, gets, events, traces, usage. | A dashboard, a metrics job. |
data | sessions.create, sessions.send, sessions.decide, sessions.streamToken, blobs.upload, previews.mint. | Your application server. |
control | Agents, providers, connections, secrets, environments, keys. | A deploy pipeline. |
Every operation in the reference states what it costs, and the
document carries it as x-plane-scope, so "what does this key need" is answerable
from the contract alone. auth.me costs any — it is how a client discovers what it
holds.
A key without a scope is refused with Forbidden, carrying what it needed and what it
held. A missing, revoked or expired one is Unauthorized, carrying the reason.
Roles, for people
A person signing in through the control plane is resolved against your identity provider's memberships on every request, not at sign-in — so revoking somebody takes effect on their next call rather than in twelve hours. A role collapses onto the same three scopes:
| Role | Scopes |
|---|---|
owner, admin | read, data, control |
member | read, data, control — except no control inside a production environment |
viewer | read |
An unrecognised role reads as viewer.
Spend caps
Caps are set on an environment and on individual keys, and are checked at
sessions.create, sessions.send and when an agent spawns a sub-agent — never
mid-turn.
await client.environments.update({
slug: "dev",
spend: { costUsdPerMonth: 50, tokensPerMonth: 20_000_000 }
})A running turn always finishes, and answering an approval
(sessions.decide) is never gated: refusing one would strand a session in
requires_action forever, which is worse than one turn of overage. Exceeding a cap is
SpendLimitExceeded — a 402, not a 429, because a monthly cap is not a rate and
retrying it with backoff is exactly wrong. See usage and spend.
Retention
Each environment carries a retention policy with five windows in days. An omitted field means unlimited, never "off". See retention.