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 devplane 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
- Loads your
definePlaneexport —plane.config.tsfirst, thenplane/agent.ts. TypeScript goes throughtsx, so there is no build step. - Mints a channel token and opens a socket to the hosted Plane.
- Registers the connection as
transport: "channel"— nourl, nohooksUrl— and upserts the agent version. - 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.
- 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
| Flag | Default | |
|---|---|---|
-c, --config <path> | plane.config.ts, then plane/agent.ts | the module exporting your definePlane result |
-n, --name <name> | your git user name | namespace: suffixes the agent slug and the connection |
--plane-url <url> | $PLANE_URL | the hosted Plane |
--api-key <key> | $PLANE_API_KEY | required |
--open | print the control-plane URL for your agent | |
--no-watch | do not re-sync when the definition changes | |
-q, --quiet | do 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.