Plane docs
Guides

Local development

Your app dials Plane. No tunnel, no public URL, no Plane on your laptop.

publicUrl is the problem with a deployed setup while you are still writing the code: your laptop has no address, so Plane cannot dial it. The usual answer is ngrok. Plane's answer is that your app dials Plane.

export PLANE_URL=https://plane.example.com
export PLANE_API_KEY=pl_dev_jordan_…      # the key is the environment this writes to

npx plane dev
plane dev  https://plane.example.com
  agent      support-demo-jordan
  channel    dev_jordan (outbound — no public URL, no tunnel)
  tools      lookup_order, create_return, track_parcel, …
  console    https://plane.example.com/agents?slug=support-demo-jordan

08fjbpxv  user Where is order A-1001, and can I return it?
08fjbpxv  → lookup_order
08fjbpxv  served lookup_order 4ms
08fjbpxv  ← lookup_order {"found":true,"order":{"id":"A-1001",…
08fjbpxv  gate ask
08fjbpxv  idle (requires_action)

and are the log's tool calls, so they cover every tool the agent calls — a builtin, a sandbox tool, a third-party server. served is the subset your process actually ran, with the time your handler took. gate is what your onToolCall returned.

What it does

  1. Loads your definePlane export — plane.config.ts first, then plane/agent.ts. TypeScript goes through tsx, so there is no build step.
  2. Mints a channel token and opens a socket to the hosted Plane.
  3. Registers the connection as transport: "channel"no url, no hooksUrl — and upserts the agent version.
  4. Watches the config's directory and re-imports and re-syncs on change. A syntax error in a half-saved file logs and leaves the previous module serving.
  5. Streams the agent's session events, coloured per session.

MCP requests, event hook deliveries and the synchronous tool-call gate all arrive as frames on that one socket and are answered by the same handlers your deployed app serves over HTTP. There is no development-only code path to drift.

Options

FlagDefault
-c, --config <path>plane.config.ts, then plane/agent.tsthe module exporting your definePlane result
-n, --name <name>your git user namenamespace: suffixes the agent slug and the connection
--plane-url <url>$PLANE_URLthe hosted Plane
--api-key <key>$PLANE_API_KEYrequired
--openprint the control-plane URL for your agent
--no-watchdo not re-sync when the definition changes
-q, --quietdo not stream session events

--name exists because two developers sharing one Plane must not overwrite each other's agent version: support-demo becomes support-demo-jordan, agent slug and connection together. A personal environment (--kind personal when creating one) is the intended target, which is why there is no --environment flag anywhere.

Verification is replaced, not skipped

Over HTTP a per-request JWS proves the caller is Plane. Over a channel the socket proved it: your process presented a Plane-signed channel token at the upgrade and everything since arrived on that one TLS stream. ctx.claims, ctx.connection and ctx.session are the same shape either way, so your tool code cannot tell which transport reached it.

Owning the process yourself

const dev = await plane.dev({ planeUrl: process.env.PLANE_URL, name: "jordan" })
await dev.ready
// …
await dev.close()

The socket code is loaded on demand, so importing @plane/sdk in a deployed app does not pull it in.

Two things that are deliberate

There is no local Plane. A second Plane is a second implementation, and its divergence from the real one is invisible until it costs a day — a different Postgres, different provider configuration, no real signing keys, a different sandbox provisioner. Developing against the hosted control plane means the turn loop, the prompt layout, the cache breakpoints and the event log are the ones that will run in production.

There is no tunnel. A public URL for half-written code, a URL that changes on every restart, a third party in the loop, and a signature audience that moves with it.

On this page