Defining an agent in code
definePlane puts the versioned definition and the code its tools run in one object.
An agent has two halves that must agree: a versioned definition that lives in Plane, and the implementation of its tools, which lives in your app and touches your database. Keeping them in sync by hand is where agent deployments rot.
definePlane makes them one object.
// lib/plane.ts
import { definePlane, tool, createPlaneClient } from "@plane/sdk"
import { z } from "zod"
export const plane = definePlane({
agent: {
slug: "support-bot",
name: "Support bot",
model: {
provider: "gateway", // a provider *name* registered with Plane
model: "anthropic/claude-sonnet-4.6",
reasoning: "low",
fallback: { models: [{ model: "openai/gpt-5.6-luna" }] }
},
system: "You are a support agent. Look things up before answering.",
limits: { maxTurns: 12 },
overrides: { model: false, reasoning: true, system: "append" }
},
tools: [
tool({
name: "lookup_order",
// Descriptions are what the model chooses on — and, with deferred loading,
// what discovery searches. Say what it does, not what it is.
description: "Where an order is right now, by order id",
input: z.object({ orderId: z.string() }),
annotations: { readOnlyHint: true },
handler: async ({ orderId }, ctx) => {
const order = await orders.get(orderId)
// ctx: { session?, connection, claims, signal } — all verified.
if (order?.customerId !== ctx.session?.metadata?.customerId) {
return { found: false, error: "not your order" }
}
return { found: true, order }
}
})
],
hooks: {
onSessionEvent: async (event) => {
if (event.type === "agent.message") await recordTranscript(event)
}
},
publicUrl: process.env.PLANE_PUBLIC_URL,
client: createPlaneClient({ baseUrl: process.env.PLANE_BASE_URL! })
})Zod v4 is a peer dependency. input is typed as any Standard Schema, but the MCP
server derives the published JSON Schema from the schema object itself, and zod is
what it understands.
Serving it
One catch-all route serves both the MCP endpoint and the hooks endpoint.
// app/api/plane/[...plane]/route.ts
import { plane } from "@/lib/plane"
export const { GET, POST, DELETE } = plane.handlersThe handlers take a Web Request and return a Web Response, so the same export
works in Next, Hono, Remix, SvelteKit, Deno, Bun and Cloudflare Workers. The SDK does
not depend on Next. Defaults are /api/plane/mcp and /api/plane/hooks; override
with mcpPath and hooksPath.
Verification happens before anything reaches a tool — see MCP and request signing.
Syncing
const { agent, connection, environment } = await plane.sync({
publicUrl: "https://app.example.com"
})
console.log(`synced to ${environment.slug} (${environment.kind})`)Two idempotent upserts, in this order:
connections.upsert— name = the agent slug,auth: { type: "plane-signed" },url=publicUrl + mcpPath, plushooksUrlandhookEventswhen a session hook is defined.agents.upsert— the definition fromplane.definition(), whosetoolsare the builtins you declared plus one{ type: "mcp", connection: <slug>, allow: [<every tool name>] }entry, plus one inline entry permcpServers.
agents.upsert appends a version only when the definition actually changed, so
running sync on every boot is fine. plane.definition() returns the same document
without talking to Plane — assert on that in a test, or print it in a build step.
One line of CI output
environment.slug is printed by the demo before it exits. That is the whole
mitigation for pushing to the wrong environment, and the key is what decides it:
PLANE_API_KEY=pl_prod_… plane sync.
Driving it
const { id } = await plane.sessions.start({
message: "Where is my order?",
metadata: { customerId: "cus_42" },
identity: { scope: "user", id: "u_412" }
})
for await (const event of plane.sessions.stream(id)) {
if (event.type === "agent.message") console.log(event.text)
}Before you have a public URL
npx plane dev serves the same handlers to a hosted Plane over an outbound socket —
no tunnel, no URL, no Plane on your laptop. See
local development.