Quickstart
A key, a provider, an agent, a session, and a stream.
Everything below is one environment's worth of setup. Set PLANE_BASE_URL to your
Plane and follow along in either column.
What you need
A Plane deployment, an API key with control, and a model provider key of your own
(Anthropic, OpenAI, or a Vercel AI Gateway key). Plane never ships model credentials.
1. Get an API key
On a fresh server the first control key is printed to the log at boot. After that,
the control plane's API keys page and the CLI are the two ways to get one.
export PLANE_URL=https://plane.example.com
export PLANE_API_KEY=pl_dev_… # a key that already has `control`
npx plane keys create --name backend --scopes read,data
# prints the plaintext once, alone on its line, and never againThe key is the environment: everything it reads and writes is scoped to the
environment it was minted in. pl_prod_… writes to prod;
pl_dev_jordan_… writes to Jordan's. There is no header that redirects one.
2. Register a provider
A provider is a named row holding an adapter type, an optional base URL and your
key. ModelConfig.provider refers to it by that name, so two accounts of the same
kind are two providers and an agent definition never contains a host or a credential.
import { createPlaneClient } from "@plane/sdk/client"
const client = createPlaneClient({
baseUrl: process.env.PLANE_BASE_URL!,
apiKey: process.env.PLANE_API_KEY // this is also the default
})
await client.providers.upsert({
name: "gateway",
type: "vercel-ai-gateway", // or "anthropic" | "openai" | "openai-compatible"
secret: process.env.AI_GATEWAY_API_KEY!
})
// A live probe against the provider's own catalogue endpoint. Costs no tokens.
await client.providers.test({ name: "gateway" })Omitting secret on a later upsert keeps whatever Plane already stores, which is what
makes a deploy script idempotent without holding the credential.
3. Upsert an agent
const { agentId, version } = await client.agents.upsert({
slug: "support-bot",
definition: {
name: "Support bot",
model: {
provider: "gateway", // the name from step 2
model: "anthropic/claude-sonnet-4.6",
reasoning: "low"
},
system: "You are a support agent. Look things up before answering.",
limits: { maxTurns: 12 }
}
})upsert appends a new version only when the definition actually changed, so running
it on every deploy does not churn the history.
4. Start a session
const session = await client.sessions.create({
agentId,
initialMessage: "where is order A-1001?",
// Yours; Plane never interprets it, and it is what `sessions.list` filters on.
metadata: { customerId: "cus_42", locale: "en-GB" },
// Who this session acts for. Optional — see Identity.
identity: { scope: "user", id: "u_412", display: "Ada Lovelace" }
})5. Send another message
await client.sessions.send({
id: session.id,
message: "and can I return it?",
mode: "enqueue", // the default; "steer" folds into a running turn
idempotencyKey: requestId // a duplicate returns the original and runs no turn
})6. Read what happened
Every session is an append-only log with a dense seq. Read it as a page, or tail it.
const { events } = await client.sessions.events({ id: session.id, after: 0 })
for (const event of events) {
if (event.type === "agent.message") console.log(event.text)
}
// Or tail it, server-side, as an async iterable.
for await (const event of client.sessions.stream(session.id, { after: 0 })) {
if (event.type === "agent.message") console.log(event.text)
if (event.type === "session.status" && event.status === "idle") break
}7. Stream it to a browser
Mint a short-lived, session-scoped, read-only token on your server and hand it to the page. An API key is deliberately not accepted on the stream route.
// app/api/watch/route.ts
const { url, token, expiresAt } = await client.sessions.streamToken({
id: sessionId,
format: "ai-sdk", // or "ag-ui" | "plane"; omit to leave it unpinned
ttlSeconds: 300
})
return Response.json({ url, token, expiresAt })