Poly is a tmux pane orchestration tool. This document explains the philosophy behind its design and the rules extensions must follow. Read this before writing an extension.
Poly follows the Unix and Lisp tradition: a small kernel of primitives that compose into arbitrarily complex behavior. The core provides pane operations (pane:new, pane:close, pane:list, pane:get-meta, pane:update-meta, pane:activate) and an event system. That's it. Everything else — task management, agent orchestration, policy enforcement, logging — is an extension.
This is a deliberate constraint. In v0.x, the core can still evolve when needed for correctness and composability, but we keep the primitive set intentionally small.
Poly is currently a greenfield v0.x project. We do not guarantee backward compatibility yet; breaking changes are acceptable when they simplify the model or improve correctness.
The pane primitives are the atoms. Extensions are the molecules.
A pane is a tmux window with two things bolted on: an ID (@poly-id, an 8-char hex string) and a JSON metadata bag (@poly-meta). The metadata is completely unstructured — poly does not know or care what's in it. This is the key design decision: metadata is the universal state layer.
An agent pane and a build-runner pane look identical to poly. The difference lives in their metadata ({ role: "coder", agent: "claude" } vs { role: "builder", command: "make" }), and the extensions that interpret that metadata decide what to do with it.
Typed fields (pane.role, pane.agent) would require the core to know about roles and agents. Then someone needs pane.prompt, then pane.parent, then pane.taskId. The core becomes a grab-bag of every concept anyone ever needed. Unstructured metadata pushes this complexity to the edges where it belongs. Extensions define their own conventions and validate with Zod at the boundary.
Poly uses a pub/sub event system, not direct callbacks or hooks. When an action executes, it emits action:start, action:success, or action:error. When tmux detects a pane died, it emits pane:exited. Extensions subscribe to these events and react.
This decoupling is essential:
- The emitter doesn't know who's listening. Core actions don't need to be modified when you add logging, metrics, or policy enforcement.
- Multiple listeners compose. A logger, a task tracker, and a policy gate can all listen to
action:successindependently. - Cancellation is opt-in. Only
action:starthandlers can cancel (by returning{ cancel: true }). All other events are fire-and-forget notifications.
action:start handlers run sequentially, each awaited before the next. This is required for cancellation — you need to know if an earlier handler vetoed before running the next.
All other event handlers run concurrently in the background (fire-and-forget). The runner does not await them — it dispatches each handler and returns immediately. A per-handler backpressure limit (MAX_IN_FLIGHT=10) prevents a single slow handler from accumulating unbounded concurrent invocations; excess events are dropped with a warning. Each handler invocation has a timeout (default 5s, configurable via POLY_EVENT_HANDLER_TIMEOUT_MS).
The rule for extension authors: handlers are observers, not orchestrators. React and return. The one exception is action:start, where blocking is intentional because the return value decides whether the action proceeds.
For non-start events, handler errors are logged, swallowed, and surfaced as handler:error events. This is correct — a buggy handler in extension A must not break extension B's ability to react.
Every extension exports a name. Actions and emitted events are auto-prefixed with it:
poly.registerAction({ name: "create" })→ registerstask:createpoly.emit("created", payload, ctx)→ emitstask:createdpoly.on("action:success", ...)→ uses the full event name (no prefix)poly.executeAction("pane:new", ...)→ uses the full action name (no prefix)
The asymmetry is intentional: registerAction and emit are scoped to your extension (you can only register your own actions and emit your own events), while on and executeAction reach across namespaces (you need to listen to core events and call other extensions' actions).
This makes it impossible for an extension to emit action:start or server:shutdown — those prefixes are reserved for core. Extensions that emit events can only emit their-name:something.
The loader enforces this by reserving extension names that collide with core namespaces (action, server, pane, shell, extensions, session).
The .inner.tmux.conf hooks use poly emit pane:exited rather than poly action some:cleanup. This is the event-vs-command distinction:
Hooks are notifications, not commands. When a pane dies, tmux is reporting a fact: "this pane exited." It is not deciding what should happen next. Maybe an extension restarts the pane. Maybe another logs it. Maybe nothing happens. That decision belongs to the extensions listening to the event, not to the tmux hook.
If hooks called actions directly, they'd be opinionated about the response to lifecycle changes. You'd need to modify .inner.tmux.conf every time you wanted different behavior — exactly the kind of coupling the event system exists to avoid.
The hooks go through the CLI (poly emit) rather than curl because tmux hooks run shell commands, and the CLI handles server discovery, port resolution, and error formatting. It's the idiomatic shell interface to poly.
Extensions must not:
-
Touch core primitives. The six public
pane:*actions are the foundation. Don't modifycore-actions.ts, don't add newpane:*actions, don't wrap them with "improved" versions that change semantics. Compose them. -
Add tmux hooks. The tmux configuration is internal infrastructure. If you need a new lifecycle event, request it as a new poly event — don't inject tmux hooks. Extensions that depend on tmux internals will break if the backend changes.
-
Assume pane contents. Poly manages panes. It does not know what's running inside them. An extension that parses tmux pane output or sends keystrokes is fragile and coupled to specific tools. Use metadata and the event system instead.
-
Store state in the server process. Module-level
Maps andSets are lost when the server restarts. Long-running work belongs in panes. If an extension needs persistent state, use files or pane metadata.
Poly is not an agent framework. It doesn't know what agents are. But the primitives — panes with metadata, events, and an action system — are sufficient to orchestrate any number of agents of any type.
The pattern:
- Pane metadata holds identity.
{ agent: "claude", role: "reviewer", prompt: "Review for security issues" }— stored viapane:update-meta, read by the agent's own hook system. - Agent hooks read metadata dynamically. Claude Code's
SessionStarthook callspoly action pane:get-meta --paneId "$POLY_PANE". OpenCode'ssession.createdplugin does the same via HTTP. The agent configures itself from its own metadata. - Changing metadata changes behavior. Update a pane's
roleorpromptviapane:update-metafrom anywhere — another pane, the CLI, an extension. The next hook invocation picks up the new state.
This is the key insight: poly is the state layer, agent hooks are the policy layer. One uniform interface in (metadata), many interfaces out (each agent's hook format). The complexity lives at the edges.
Parent-child relationships, supervisor trees, task assignment — these are all metadata conventions, not core features:
{ parent: "a1b2c3d4", role: "coder", agent: "claude" }
An extension that spawns child panes sets parent: ctx.caller in the metadata. Querying the hierarchy is pane:list + filtering by meta.parent. No special API needed. A supervisor pane spawns workers, workers point to their parent, and the tree is walkable with the existing primitives.
This means you can build any topology — flat pools, deep trees, DAGs — without poly knowing about any of them. The extension defines the convention, the metadata stores the state, and the agents' hooks interpret it.
A built-in parent/child system would impose one topology. Some use cases want flat pools ("spawn 5 coders, no hierarchy"). Some want deep trees ("supervisor → team leads → coders"). Some want dynamic re-parenting. Metadata handles all of these because it's unstructured. A parent field is a convention, not a constraint.
Orchestration is the composition of agents and tasks. It is not a feature of either — it is the policy that connects them.
Agents and tasks must not know about each other. The agent extension manages agent lifecycle: create panes, send messages, track agent state. The task extension manages task lifecycle: create, block, start, complete, close. Neither imports, references, or assumes the other exists. They are independent atoms.
The connection between them is a third extension — an orchestrator — that listens to events from both and composes their actions. This is the atoms → molecules pattern applied to coordination:
┌─────────┐ events ┌──────────────┐ events ┌─────────┐
│ task │ ──────────────→ │ orchestrator │ ←────────────── │ agent │
│ ext │ ←────actions─── │ (policy) │ ───actions────→ │ ext │
└─────────┘ └──────────────┘ └─────────┘
If you don't load the orchestrator, tasks and agents work independently. If you do, they're wired together. Load a different orchestrator, different policy. The coupling lives in a removable extension, not baked into the primitives.
Agents self-report — orchestrators don't observe. When an agent finishes a task, the agent calls poly action task:complete itself. The orchestrator does not watch for state changes and try to infer completion. The agent knows when it's done and what it did — that information should flow explicitly through the action system, not be reconstructed from metadata diffs. This keeps the boundary clean: agents decide when work is done, orchestrators decide what work to assign next.
The orchestrator's job is reactive event wiring:
- On
task:created— find an idle agent, assign the task, send it viaagent:message. - On
task:completed— check if this unblocks other tasks, assign newly unblocked tasks to idle agents. - On
pane:exited— if the dead pane was an agent with an active task, handle failure (retry, reassign, or close the task).
Each of these is a few lines of code that composes existing actions. The orchestrator has no state of its own — it reads task state from task:list and agent state from pane metadata. It is a pure policy layer.
A monolithic extension that manages agents, tasks, assignment, retry, and supervision would be simpler to write but impossible to swap. You'd fork the whole thing to change retry policy. With separation:
- Swap the orchestrator to change assignment strategy (round-robin, priority queue, affinity-based) without touching task or agent logic.
- Remove the orchestrator entirely for manual workflows where a human assigns tasks via CLI.
- Run multiple orchestrators for different task categories (one for code tasks, another for review tasks) by filtering on task metadata.
- Add a supervisor extension that only handles
pane:exitedretry logic, independent of task assignment.
This is the same principle as the "settings extension" pattern — behavior as loadable, removable policy.
Long-running processes — file watchers, build servers, monitoring loops — should run in panes, not in the server process. A pane gives you:
- An ID — track it, close it, query it.
- Metadata — tag it with
{ role: "watcher", target: "/path" }, find it viastdlib:filter. - Lifecycle events —
pane:exitedfires if it dies, extensions can react. - Server independence — panes survive server restarts because they're tmux processes, not server state.
The anti-pattern is holding OS resources (file descriptors, sockets, child processes) inside the server process via extensions. If the server restarts, those resources leak or die. A pane-based approach delegates resource ownership to tmux, which is designed for exactly this.
# Good: background work in a pane
poly action pane:new --argv '["fswatch", "/path"]' --meta '{"role":"watcher"}'
# Bad: background work in an extension
const watcher = chokidar.watch("/path") // lives in server memory, dies on reload
For one-shot side effects (a quick git fetch, a notification), poly.spawn() or shell:run is fine — they complete and exit. The rule is about persistent background work: if it outlives a single request, it belongs in a pane.
Good extensions are thin. They:
- Compose core actions.
executeAction("pane:new", ...)+executeAction("pane:update-meta", ...)= a higher-level spawn command. - Define metadata conventions. Agree on field names (
role,agent,parent,status) and validate with Zod at the action boundary. - React to events. Listen to
action:success,pane:exited, etc. and take appropriate action. - Pass context through. Always forward
ctxtoexecuteActionandemitso caller identity propagates through the call chain. - Wrap external tools. If a CLI tool does what you need, write a thin extension that translates between poly's action system and the tool's CLI via
executeAction("shell:run", ...)orexecuteAction("pane:new", ...).
- Don't add features to core. If you want
pane:restart, write an extension that doespane:close+pane:new. Don't modify core-actions.ts. - Don't be generic. An extension that "might need" configuration, plugin systems, or abstract factories is over-engineered. Write the specific thing you need.
- Don't block event handlers. Await only in
action:start(where cancellation requires it). Everything else: fire and forget. - Don't duplicate state. If it's in metadata, don't also cache it in a module-level variable. Read it when you need it.
- Don't invent new transport. Use
executeActionto call other actions,emitto send events. Don't open sockets, write to temp files, or use IPC.
TypeScript extensions should import ExtensionAPI from poly/types.
Panes persist across server restarts (they're tmux processes). If your extension creates a pane at load time, it should check for an existing one first to avoid duplicates:
export const name = "my-worker"
export default async function(poly: ExtensionAPI) {
const panes = await poly.executeAction("pane:list") as Array<{ id: string; meta: Record<string, unknown> }>
const existing = panes.find(p => p.meta.managedBy === "my-worker")
if (!existing) {
await poly.executeAction("pane:new", {
argv: ["long-running-task"],
meta: { managedBy: "my-worker" },
})
}
}Use a metadata field (here managedBy) to tag panes your extension owns, then check for it at load time.
Higher-level actions are thin wrappers that call core actions and forward ctx:
export const name = "agents"
export default function(poly: ExtensionAPI) {
poly.registerAction({
name: "spawn",
parameters: z.object({
agent: z.string(),
role: z.string(),
prompt: z.string().optional(),
}),
execute: async (params, ctx) => {
return poly.executeAction("pane:new", {
argv: [params.agent],
meta: { agent: params.agent, role: params.role, prompt: params.prompt, parent: ctx.caller },
}, ctx)
},
})
}Note: ctx is forwarded so the caller identity propagates. The parent: ctx.caller convention links the child to whoever spawned it.
Not every extension registers actions. Some exist purely to configure behavior by reacting to events. This is the "settings" pattern — an extension that acts as a policy layer:
export const name = "auto-parent"
export default function(poly: ExtensionAPI) {
poly.on("action:success", async (event, ctx) => {
if (event.actionName === "pane:new" && ctx.caller) {
const result = event.result as { id: string }
poly.executeAction("pane:update-meta", {
paneId: result.id,
set: { parent: ctx.caller },
}, ctx)
}
})
}This extension has zero actions. Loading it changes system behavior — every pane automatically gets a parent field. Removing it restores the default. This is how you configure poly without flags or config files: load or unload an extension.
action:start is the only event where the return value matters. Use it to enforce rules:
export const name = "policy"
export default function(poly: ExtensionAPI) {
const blocked = new Set(["experimental-ext"])
poly.on("action:start", async (event) => {
const [extName] = event.actionName.split(":")
if (extName && blocked.has(extName)) {
return { cancel: true, reason: `Extension "${extName}" is disabled by policy` }
}
})
}This is the only handler type where you should await slow work — because the return value decides whether the action proceeds.
For any event other than action:start, don't block:
// ✗ Bad — blocks all downstream handlers and SSE delivery
poly.on("action:success", async (event, ctx) => {
await fetch("https://logging-service.com/ingest", {
method: "POST",
body: JSON.stringify(event),
})
})
// ✓ Good — returns immediately, work runs in background
poly.on("action:success", async (event, ctx) => {
fetch("https://logging-service.com/ingest", {
method: "POST",
body: JSON.stringify(event),
})
})| Variable | Default | Purpose |
|---|---|---|
POLY_PORT |
4096 | Server port |
POLY_HOSTNAME |
127.0.0.1 | Server bind address |
POLY_TMUX_SOCKET |
poly | Isolated tmux socket name |
POLY_TMUX_CONFIG |
src/.inner.tmux.conf | Tmux config for the inner session |
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 |
(set per pane) | The pane's own ID — injected into every tracked pane |
Poly v1 optimizes for local single-user workflows. These are explicit non-goals for v1:
- Built-in authentication/authorization for exposed network deployments
- Multi-user tenancy and permission boundaries
- Internet-facing hardening beyond localhost defaults
Why no authentication? Poly binds to localhost. If someone has access to your localhost, they already have access to everything poly could protect. Adding auth would add complexity for zero security gain in the target deployment model (single-user, local dev tool). A token-per-session scheme would also leak into the extension API — every extension that calls the HTTP API or composes actions would need the secret, complicating createExtensionAPI and adding friction to every extension author. The real attack surface is the extensions themselves (arbitrary code execution by design), and the answer there is "don't install extensions you don't trust" — same as any plugin system.
Why no action timeouts? Extensions can wrap their own logic with Promise.race. A global timeout in the runner would be wrong for long-running operations and right for short ones — there's no correct default. Let the caller decide.
Why no sandbox for extensions? Extensions are user-authored code in ~/.poly/extensions/. They're .bashrc for poly — if you put malicious code there, that's on you. Sandboxing would restrict legitimate use cases (filesystem access, network calls, subprocess spawning) for no real security benefit.
Why no extension dependency ordering? Extensions load alphabetically. If B depends on A, they can communicate via events (poly.on / poly.emit) rather than assuming load order. Combining them into a single file also works. Wait for a real use case before adding complexity.
Why fire-and-forget for handler errors? A buggy logger extension must not prevent a task tracker extension from reacting to the same event. Handler isolation is more important than error propagation.
Why module-level mutable state instead of a context object? Adds ~15 lines of boilerplate for no meaningful benefit in a single-process server. The tradeoff is that tests must call resetState() — acceptable.
Poly uses two related but distinct naming systems:
- Reserved extension names (
action,server,pane,shell,extensions,session) prevent user extensions from colliding with core action and lifecycle namespaces.
These lists are intentionally different. stdlib is a valid extension name because that is a source-identity concern, not a core-action-namespace concern.
Implementation detail: extension name is still used as both namespace prefix and source label in src/extensions/api.ts. That coupling is acceptable for the current model (single-user local tool, deterministic load order, duplicate names rejected). If the extension model gets more complex later, source identity can be separated from extension names.