Plane docs
Guides

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.handlers

The 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:

  1. connections.upsert — name = the agent slug, auth: { type: "plane-signed" }, url = publicUrl + mcpPath, plus hooksUrl and hookEvents when a session hook is defined.
  2. agents.upsert — the definition from plane.definition(), whose tools are the builtins you declared plus one { type: "mcp", connection: <slug>, allow: [<every tool name>] } entry, plus one inline entry per mcpServers.

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.

On this page