A tmux pane orchestration tool with an HTTP API and extension system. Inspired by pi-mono.
npm installTo put poly on your PATH:
npm linkPoly is currently a greenfield v0.x project. We optimize for correctness and simplicity over backward compatibility. Breaking changes to actions, payloads, and extension APIs are allowed between minor releases.
poly server start # Start the HTTP server (default: 127.0.0.1:4096)
poly session start # Create the tmux session
poly action pane:new --argv vim # Create a pane running vim
poly action pane:list # List all tracked panes
poly session attach # Attach to see your panespoly server start [--port 4096] [--hostname 127.0.0.1]
poly server stop
poly server statuspoly is designed for localhost use. Binding to non-loopback hosts (for example --hostname 0.0.0.0) exposes
an unauthenticated control API and is only safe in tightly controlled environments.
Poly manages panes headlessly — you never see them unless you attach. Use poly session attach to visually inspect panes or debug.
poly session start # Create the tmux session (required before pane:new)
poly session stop # Kill session and all panes
poly session attach # Attach to the tmux session# Inline params
poly action pane:new --argv vim --argv .
poly action pane:close --paneId <id>
# Set a metadata key
poly action pane:set-meta --paneId <id> --key role --value agent
# Delete metadata keys (repeated flags for arrays)
poly action pane:delete-meta --paneId <id> --keys role --keys model
# JSON body (alternative — cannot be combined with inline params)
poly action pane:set-meta --body '{"paneId": "<id>", "key": "role", "value": "agent"}'poly emit pane:ready --paneId <id>
poly emit my-event --body '{"key": "value"}'poly list # List all registered actions| Action | Parameters | Returns | Description |
|---|---|---|---|
pane:new |
id?, argv?, cwd?, env?, meta?, persist? |
{ id, meta } |
Create a tracked pane |
pane:close |
paneId |
{} |
Close a pane |
pane:list |
(none) | [{ id, meta }] |
List all tracked panes |
pane:get-meta |
paneId |
{ id, meta } |
Get pane metadata |
pane:set-meta |
paneId, key, value |
{ id, previousMeta, meta, changed } |
Set a single metadata key |
pane:delete-meta |
paneId, keys |
{ id, previousMeta, meta, changed } |
Delete one or more metadata keys |
pane:update-meta |
paneId, set?, deleteKeys? |
{ id, previousMeta, meta, changed } |
Atomically set and/or delete multiple keys |
pane:activate |
paneId |
{} |
Focus/switch to a pane |
shell:run |
command, cwd? |
{} |
Run a shell command in the background (output discarded; use pane:new to capture output) |
The agent:* actions ship with poly and are always available — no installation needed.
Create and manage agent panes.
| Action | Description |
|---|---|
agent:create |
Spawn an agent pane with initialized state metadata (agent.runtime, agent.status, etc.) |
agent:message |
Send a message to an agent pane |
agent:set-state |
Update normalized agent state metadata |
agent:create accepts argv (required), plus:
| Parameter | Description |
|---|---|
id? |
Custom pane ID |
cwd? |
Working directory |
env? |
Extra environment variables |
meta? |
Additional metadata to merge |
model? |
Model alias. For claude: haiku, sonnet, or opus. Injected as --model <value>. |
permissions? |
"ask" (default) or "auto" — "auto" bypasses permission prompts |
prompt? |
Initial message passed as a positional argument on startup |
It emits agent:created on success.
POST /action/:name Execute an action (JSON body)
POST /emit/:event Emit an event (JSON body)
GET /actions List all registered actions
GET /events SSE stream (?include=pattern,other:*)
GET /health Server status
Caller identity is sent via the x-poly-pane header.
The ?include= query parameter controls which events are delivered:
GET /events?include=action:*,my-ext:* # Action events + my-ext events
GET /events?include=* # All events
GET /events # No events (warning logged)
Supports exact match (action:success), prefix wildcard (action:*), or catch-all (*).
SSE frames include monotonic integer IDs. Reconnect with the Last-Event-ID header to replay missed events from a ring buffer (last 1000 events).
Extensions are package directories in ~/.poly/extensions/, loaded dynamically via jiti. Core and agent actions are built into the binary and always available.
# Install from a git URL
poly ext install https://github.com/user/poly-ext-example
# List all extensions
poly ext list
# Remove a user extension
poly ext remove exampleEach extension is a directory in ~/.poly/extensions/ with a package.json (must have a main field) that exports a name and a default factory function:
~/.poly/extensions/
└── my-extension/
├── package.json # Required; must include "main"
├── node_modules/
└── src/
└── index.ts
// src/index.ts
import { z } from "zod"
import type { ExtensionAPI } from "poly/types"
export const name = "my-ext"
export default function(poly: ExtensionAPI) {
poly.registerAction({
name: "greet", // Registered as "my-ext:greet"
parameters: z.object({ message: z.string() }),
execute: async (params, ctx) => {
return { echo: params.message }
},
})
poly.on("action:success", async (event, ctx) => {
// React to any successful action
})
// Emit custom events (auto-prefixed: "my-ext:something")
// await poly.emit("something", { data: 123 })
// Call other actions
// const panes = await poly.executeAction("pane:list")
}Extension names must match [a-z0-9][a-z0-9-]* and cannot use reserved names (action, server, pane, shell, extensions, session).
Discovery only loads directories with package.json + string main. Loose files are ignored. Poly does not auto-install extension dependencies; run npm install inside the extension directory before loading.
| Method | Description |
|---|---|
poly.cwd |
The working directory the server was started in |
poly.registerAction(def) |
Register an action (auto-prefixed with extension name) |
poly.on(event, handler) |
Subscribe to lifecycle or custom events |
poly.emit(event, payload) |
Emit a custom event (auto-prefixed) |
poly.executeAction(name, params) |
Call any registered action |
See DESIGN.md for extension philosophy, patterns, and design rationale.
If you run OpenCode inside Poly panes, you can sync runtime state into pane metadata using
the included plugin at .opencode/plugins/poly-agent-state.ts.
The plugin calls agent:set-state (from the bundled agent extension) over Poly HTTP for all state updates.
If Poly is not on the default http://127.0.0.1:4096, set POLY_BASE_URL in the agent pane
environment so the plugin targets the correct server.
The plugin listens for OpenCode lifecycle events and updates these generic keys:
agent.runtime(for example"opencode")agent.sessionIdagent.status(for examplerunning,idle,error)agent.erroragent.lastEventAt
This keeps metadata provider-agnostic so other agent runtimes can reuse the same schema.
All shared runtime hook files now live under hooks/ with setup instructions in
hooks/README.md.
| Event | Payload | Notes |
|---|---|---|
action:start |
{ actionName, params } |
Handler can return { cancel: true } to veto |
action:success |
{ actionName, params, result } |
|
action:error |
{ actionName, params, errorInfo } |
|
handler:error |
{ source, event, error } |
Fired when an event handler throws |
pane:created |
{ paneId } |
Fired for poly-tracked panes only |
pane:exited |
{ paneId, exitCode } |
Fired when pane process exits |
pane:activated |
{ paneId } |
Fired on focus change |
session:started |
{} |
Fired when the tmux session is created |
session:stopped |
{} |
Fired when the tmux session is closed |
session:attached |
{} |
Fired when a client attaches |
session:detached |
{} |
Fired when a client detaches |
server:started |
{} |
|
server:shutdown |
{} |
The server emits structured JSON lines to stderr via log(). No action traces or request logs by default. If you want logging, write an extension:
export const name = "logger"
export default function(poly: ExtensionAPI) {
poly.on("action:start", async (event) => { console.log("[>]", event.actionName) })
poly.on("action:success", async (event) => { console.log("[✓]", event.actionName) })
poly.on("action:error", async (event) => { console.error("[✗]", event.actionName) })
}| Variable | Default | Description |
|---|---|---|
POLY_PORT |
4096 | Server port (0 for random). Also propagated into the tmux session so CLI commands in panes resolve the correct server when the port differs from 4096. |
POLY_HOSTNAME |
127.0.0.1 | Server bind address |
POLY_TMUX_SOCKET |
poly | Tmux socket name |
POLY_TMUX_CONFIG |
src/.inner.tmux.conf | Tmux config path |
POLY_SESSION_NAME |
poly | Tmux session name |
POLY_EXTENSIONS_DIR |
~/.poly/extensions | Global extensions directory |
POLY_EVENT_HANDLER_TIMEOUT_MS |
5000 | Max time (ms) an event handler can run before timeout |
POLY_PANE |
(per pane) | Pane's own ID |
Poly v1 is intentionally a local, single-user development tool. It ships without authentication and assumes the
server is bound to loopback (127.0.0.1) unless you explicitly opt out.
Poly is a local dev tool. Be aware:
- No authentication. Anyone who can reach the server can execute actions.
- No caller verification. The
x-poly-paneheader identifies callers but isn't verified. - Exclusive session management. Direct tmux commands inside the poly session create untracked panes. Use
pane:newinstead.
Never expose Poly directly to untrusted networks.
- DESIGN.md — Extension philosophy, patterns, and design rationale