Scripting and automation
Drive a running Paneflow from any shell or AI agent - 12 CLI verbs, the JSON-RPC IPC socket, declarative agent workspaces, multi-agent flow pipelines, the read-only MCP bridge, and lifecycle hooks.
Since v0.4.0 every part of Paneflow is scriptable: the paneflow
binary doubles as a CLI that drives the running GUI instance over a
local JSON-RPC socket. An agent (or a shell script) can list panes,
read and search scrollback, create workspaces and splits, move focus,
inject text and keystrokes, block on output patterns, spawn whole
declarative agent workspaces, and run multi-agent pipelines — without
the user ever explaining the app to it. This page is the complete
reference for that surface.
TL;DR for agents. Talk to Paneflow with paneflow <verb> from any
pane (12 verbs: ls, read, search, new, select, split,
focus, send, key, wait, up, flow). Output is JSON by
default. Target panes by id, name, cmdline:<substr>, or
cwd:<path>. Writing into panes (send, key, submitting flows)
requires the opt-in PANEFLOW_IPC_SCRIPTING=1 on the Paneflow
process; send never submits without an explicit --submit. Exit
codes: 0 ok, 1 runtime failure, 2 usage, 3 target not found or
ambiguous, 4 timeout. Discover the full method list at runtime with
the system.capabilities JSON-RPC call.
How do I control Paneflow from the shell?
The paneflow binary detects a known verb in argv[1] and runs as a
CLI client against the running instance instead of launching the GUI.
It connects over the IPC socket and exits before any GPUI
initialization.
| Verb | What it does | Writes to panes? |
|---|---|---|
ls | List the active workspace's panes (JSON; --human for a table) | No |
read <target> | Print a pane's scrollback (--lines, --offset, --json) | No |
search <target> <pattern> | Search a pane's scrollback (--max, --human) | No |
new | Create a workspace (--name, --cwd) | No |
select <index> | Select a workspace by zero-based index | No |
split <h|v> | Split a pane (--target to pick which one) | No |
focus <target> | Give a pane keyboard focus (switches workspace/tab too) | No |
send <target> <text> | Inject text, never submits without --submit (--broadcast) | Gated |
key <target> <keystroke> | Send one named keystroke (escape, ctrl-c, …) | Gated |
wait --match <sel> --pattern <re> | Block until a regex appears in a pane (--timeout, --any/--all) | No |
up <file> | Spawn a declarative agent workspace from TOML (--dry-run) | Prefill only |
flow run <file> | Execute a multi-agent pipeline from flow.toml (--dry-run, --json) | Gated for submits |
Run any verb with --help for its flags. If the CLI runs outside a
Paneflow pane, point it at the instance with the PANEFLOW_SOCKET_PATH
environment variable (inside a pane it is injected automatically).
How do I target a pane?
Every verb that takes a <target> resolves it against surface.list
with one grammar:
| Selector | Example | Matches |
|---|---|---|
| Numeric id | paneflow read 42 | The pane with surface_id 42 |
| Name | paneflow read backend | The pane named backend |
cmdline:<substr> | paneflow read cmdline:vite | The pane whose foreground command contains vite |
cwd:<path> | paneflow read cwd:/home/me/api | The pane whose working directory matches |
A selector that matches nothing or matches several panes is an error
(exit code 3) — except send --broadcast, which fans out to every
match, and wait --any/--all, which embrace multi-match.
Portability note: cmdline: matches the full foreground argv on
Linux but only the executable basename on macOS and Windows. Prefer
cwd: or a pane name for portable scripts.
What do the exit codes mean?
| Code | Meaning |
|---|---|
0 | Success |
1 | Runtime failure: instance not running, pane closed, scripting gate off |
2 | Usage error (owned by the argument parser) |
3 | Target not found, or ambiguous without --broadcast |
4 | wait (or a flow ready barrier) hit its timeout |
Scripts can branch on the distinction: a 4 from wait means "the
pattern never appeared", not "the pane died" (that is a 1).
What is the scripting gate?
Reading is always allowed; writing into a PTY is an opt-in.
surface.send_text and surface.send_keystroke (and therefore
paneflow send, paneflow key, and any flow step with
submit = true) are disabled unless the Paneflow process was started
with PANEFLOW_IPC_SCRIPTING=1. When the gate is off, those methods
return JSON-RPC error -32601 Method not enabled.
Two more guardrails hold even with the gate on:
paneflow sendwrites the text verbatim with no trailing newline — the human (or the agent, explicitly) reviews and submits.--submitis the only way the CLI ever appends a carriage return.paneflow keyrefuses keystrokes that would submit a line (enter,ctrl-m,ctrl-j). Submission is exclusive tosend --submit.
A single send payload is capped at 64 KiB.
CLI examples
# What is running right now?
paneflow ls --human
# Last 80 lines of the pane running vite
paneflow read cmdline:vite --lines 80
# Did the test suite pass anywhere in this pane's history?
paneflow search backend "test result: ok" --max 5
# Workspace with two panes, focus the editor side
paneflow new --name api --cwd ~/dev/api
paneflow split v --target api
paneflow focus cwd:~/dev/api
# Stage a command for the user to review (no submit), then watch
PANEFLOW_IPC_SCRIPTING=1 # must be set on the Paneflow process itself
paneflow send backend "cargo test --workspace"
paneflow wait --match backend --pattern "test result" --timeout 120
# Interrupt a runaway process
paneflow key backend ctrl-cHow do I spawn an agent workspace declaratively?
paneflow up <file> builds a whole workspace from a TOML spec —
"compose for agents": per-pane working directory, agent to launch,
prompt prefill, env, optional git worktree per pane. --dry-run
validates and prints the resolved plan (agents resolved to commands,
worktrees planned, ports allocated) without touching the instance.
# paneflow.workspace.toml
name = "feat-x"
layout = "main_vertical"
[[panes]]
cwd = "~/dev/api"
agent = "claude"
prompt = "review the diff on this branch"
focus = true
[[panes]]
cwd = "~/dev/api"
command = "cargo watch -x test"
name = "tests"Top-level fields:
| Field | Type | Default | Notes |
|---|---|---|---|
name | string | "Workspace" | Workspace title |
layout | string | "even_h" | even_h, even_v, main_vertical, tiled |
port_base | integer | 3000 | Base for ${port_offset} allocation |
[[panes]] | array | required | 1 to the pane cap; unknown keys are an error |
Per-pane fields:
| Field | Type | Default | Notes |
|---|---|---|---|
cwd | string | none | ~ expands server-side; canonicalised and must exist |
agent | string | none | claude, codex, opencode, gemini, … — mutually exclusive with command |
command | string | none | Raw command — mutually exclusive with agent |
prompt | string | none | Prefilled into the agent's input, never auto-submitted |
focus | bool | false | This pane gets initial focus |
env | table | none | Merged over terminal.env; values may use ${port_offset} |
name | string | none | Explicit pane name (handy as a stable selector) |
worktree | string | none | Git branch: pane runs in its own worktree under <repo>.worktrees/ — requires cwd |
copy_env | bool | true | Copy gitignored .env* files from the repo root into the worktree |
setup | string | none | Command run in the worktree before the agent (e.g. "bun install"); failure warns, never blocks |
setup_timeout_secs | integer | 300 | Timeout for setup |
worktree_teardown | string | "auto" | "auto" removes a clean worktree when the workspace closes (the branch is never deleted); "keep" leaves it |
${port_offset} gives each pane that references it an isolated port:
allocation starts at port_base and probes in strides of 10
(pane 0 → 3000, next → 3010, skipping ports that are already bound).
It substitutes only inside env values; any other ${…} token in
the spec is a validation error. Orphaned worktrees are pruned at the
next Paneflow startup.
The whole spec fails atomically: every cwd is canonicalised before
any pane is created, so a typo never leaves a half-built workspace.
How do I run a multi-agent pipeline?
paneflow flow run <file> executes a declarative DAG from a
flow.toml: spawn steps open panes, ready barriers wait on regex
patterns in their output, send steps feed downstream panes,
foreach fans out over a list with fan-in on completion, and
capture passes output between steps.
# flow.toml
name = "review-pipeline"
layout = "even_h"
[defaults]
timeout_secs = 600
[[step]]
id = "impl"
pane = { cwd = "~/dev/api", agent = "claude", prompt = "implement the fix and run the tests" }
submit = true
ready = { pattern = "tests? passed" }
capture = { var = "summary", lines = 20 }
[[step]]
id = "review"
needs = ["impl"]
send = { target = "impl", text = "Summarise what changed:\n${summary}" }Step fields:
| Field | Type | Notes |
|---|---|---|
id | string | Required, unique, no [/] |
needs | array | Dependencies; on a foreach step it waits for all instances |
foreach | array | Fan-out: one instance per item, ${item} substituted; items must be unique |
pane | inline table | Spawns a pane — same fields as [[panes]] in paneflow up; XOR with send |
send | inline table | { target, text, submit? } — requires at least one dependency |
ready | table | { pattern, timeout_secs? } — regex barrier on the pane's recent output; a timeout must exist here or in [defaults] |
capture | table | { var, lines } — last 1–500 lines at the moment ready matched; requires ready |
submit | bool | Submit the pane's prefilled prompt; requires the scripting gate |
Variables: ${item} is available in foreach steps (in cwd,
name, worktree, env values, send.target, ready.pattern,
prompts and texts). Captured ${var} values are available only in
send.text and in a submitting pane.prompt; a foreach capture is
addressed as ${var.<item>} (e.g. ${out.api}). Substituted text
over 64 KiB is truncated with an explicit …[truncated] marker.
Everything that can fail statically fails at parse time: unknown
keys, unknown or cyclic needs (the error names the cycle path),
send steps with nothing to target, ready without a timeout,
invalid regexes (revalidated after ${item} substitution), capture
variables that are never defined, a pane budget over the cap, and a
missing bootstrap root. A flow that submits anywhere checks the
scripting gate via system.capabilities before any mutation —
including under --dry-run.
At runtime the engine ticks every 500 ms, polls barriers over the
last 500 lines of scrollback, and lets prompts settle before feeding
(two identical consecutive reads, floor 1.8 s, cap 8 s). Ctrl-C
aborts the orchestration but panes always survive. The final
report (stdout, machine-readable with --json) lists per-step
status (READY/FAILED/SKIPPED), duration, surface_id, and
error; live transitions stream to stderr.
How do I script Paneflow over raw JSON-RPC?
The CLI is a thin client over a local JSON-RPC 2.0 socket you can use directly from any language:
- Endpoint:
$XDG_RUNTIME_DIR/paneflow/paneflow.sockon Linux, the same path under the user runtime dir on macOS, and the\\.\pipe\paneflownamed pipe on Windows.PANEFLOW_SOCKET_PATHis injected into every pane. - Framing: newline-delimited JSON-RPC 2.0 — one request per line, one response per line. Clients are expected to use one connection per request.
- Trust model: strictly local. The socket is mode
0600and every connection's peer UID is checked against the server's (SO_PEERCRED/LOCAL_PEERCRED); a mismatch gets JSON-RPC error-32001before any dispatch. No tokens, no TLS, no network surface. - Limits: at most 16 concurrent connections (excess gets
-32000backpressure), a 30 s idle read deadline, a 5 s dispatch timeout (slow non-idempotent calls are cancelled rather than run late, so client retries can't double-create workspaces).
printf '%s\n' '{"jsonrpc":"2.0","method":"surface.list","params":{},"id":1}' \
| nc -U "$PANEFLOW_SOCKET_PATH"The full method list, with the live state of the scripting gate, is discoverable at runtime:
printf '%s\n' '{"jsonrpc":"2.0","method":"system.capabilities","params":{},"id":1}' \
| nc -U "$PANEFLOW_SOCKET_PATH"
# -> {"result": {"scripting": false, "methods": ["system.ping", …]}, …}| Method | Params | Returns / notes |
|---|---|---|
system.ping | — | Liveness check |
system.capabilities | — | {scripting, methods[]} — agent discovery entry point |
system.identify | — | {name: "PaneFlow", version, protocol} |
workspace.list | — | Workspaces with indexes and titles |
workspace.current | — | Active workspace |
workspace.create | name?, cwd?, layout? | cwd canonicalised, must be a directory; optional layout tree |
workspace.select | index | Switch workspace |
workspace.close | index? | Close a workspace |
workspace.up | name, layout, panes[] | Declarative spawn (used by paneflow up/flow); returns {index, title, panes, surface_ids[]} |
workspace.restore_layout | layout | Apply a layout tree to the active workspace |
surface.list | — | {surfaces: [{surface_id, name, title, cwd, cmd, workspace}]} |
surface.read | surface_id, lines?, offset? | {text, lines, total_lines, eof}; lines clamped to 1–4000 |
surface.search | surface_id, pattern, max_matches? | Substring search; max_matches clamped to 1–1000 |
surface.rename | surface_id, name | Rename a pane |
surface.focus | surface_id | Focus a pane (switches workspace/tab) |
surface.send_text | surface_id, text, submit? | Gated; 64 KiB cap; submit appends the carriage return |
surface.send_keystroke | surface_id, keystroke | Gated; refuses submitting keys; CRLF bytes rejected unconditionally |
surface.split | direction, surface_id?, cwd?, command?, prompt?, env?, name?, managed_worktree? | Returns the new surface_id; bounded by the pane cap |
ai.session_start / ai.prompt_submit / ai.tool_use / ai.notification / ai.stop / ai.session_end | hook payloads | Lifecycle telemetry from paneflow-ai-hook (see below); read-only on the UI side |
Handlers signal structured failures as JSON-RPC error envelopes
(-32602 invalid params, -32601 gated method, -32001 permission,
-32000 backpressure); a few legacy validation errors arrive as
{"error": "<message>"} inside result — treat both as failures.
How does an agent read panes over MCP?
For agents that speak MCP, paneflow-mcp is a read-only stdio
bridge over the same socket. It exposes three tools:
| Tool | Params | Returns |
|---|---|---|
list_panes | — | All panes with surface_id, name, title, cwd, cmd, workspace |
read_pane | target (name or id), lines? (default 200, max 4000), offset? | Scrollback text |
search_pane | target, pattern (plain-text, case-insensitive), max_matches? (default 50, max 1000) | line N: … matches |
Name resolution is forgiving: exact match, then case-insensitive,
then unique prefix — an ambiguous name errors with the candidate
list. All returned terminal content is wrapped in an
<untrusted_terminal_output> envelope with a random per-call id, so
a malicious process printing fake delimiters cannot break the agent
out of the quoted context. For Claude Code the bridge additionally
exposes each live pane as an MCP resource at pane://<name>/content.
Setup is one command, run from inside a Paneflow pane:
paneflow mcp install # detects agents, writes the entry in each config
paneflow mcp status # per-agent state
paneflow mcp uninstall # removes only the paneflow entryinstall covers Claude Code (~/.claude.json), Codex
(~/.codex/config.toml), Gemini CLI (~/.gemini/settings.json), and
opencode (~/.config/opencode/opencode.json) — idempotent, no-clobber,
with a .bak backup. The bridge must run from a Paneflow pane (it
reads the injected PANEFLOW_SOCKET_PATH).
How do lifecycle hooks work?
The other direction — Paneflow observing the agent — runs through
paneflow-ai-hook, a tiny callback binary that agent CLIs invoke on
lifecycle events. It reads the event JSON on stdin, posts one
JSON-RPC ai.* frame to the socket (500 ms write timeout), and
always exits 0, so a stopped Paneflow can never break an agent
session. This is what powers the per-pane status in the sidebar
(running / waiting for permission / done) and the turn-end desktop
notification when the window is unfocused.
paneflow hooks setup # persistent, user-scope hooks (Claude Code)
paneflow hooks status # per-agent state
paneflow hooks uninstall # removes only the _paneflow_managed entriesClaude Code gets persistent user-scope hooks in
~/.claude/settings.json; Codex gets per-launch hooks injected by
the shim; agents with no hook surface (Gemini, opencode) are reported
as unsupported. Without hooks setup, an ephemeral project-scope
shim covers each launch and cleans up after itself — the persistent
install simply takes authority over it.
Related
- Features — headless scripting for the product-level tour.
- Configuration schema for
paneflow.jsonkeys (terminal.envinteracts withup's per-paneenv). - Keybindings for the action names available to
shortcuts.