コンテンツにスキップ

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.

VerbWhat it doesWrites to panes?
lsList 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
newCreate a workspace (--name, --cwd)No
select <index>Select a workspace by zero-based indexNo
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:

SelectorExampleMatches
Numeric idpaneflow read 42The pane with surface_id 42
Namepaneflow read backendThe pane named backend
cmdline:<substr>paneflow read cmdline:viteThe pane whose foreground command contains vite
cwd:<path>paneflow read cwd:/home/me/apiThe 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?

CodeMeaning
0Success
1Runtime failure: instance not running, pane closed, scripting gate off
2Usage error (owned by the argument parser)
3Target not found, or ambiguous without --broadcast
4wait (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 send writes the text verbatim with no trailing newline — the human (or the agent, explicitly) reviews and submits. --submit is the only way the CLI ever appends a carriage return.
  • paneflow key refuses keystrokes that would submit a line (enter, ctrl-m, ctrl-j). Submission is exclusive to send --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-c

How 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:

FieldTypeDefaultNotes
namestring"Workspace"Workspace title
layoutstring"even_h"even_h, even_v, main_vertical, tiled
port_baseinteger3000Base for ${port_offset} allocation
[[panes]]arrayrequired1 to the pane cap; unknown keys are an error

Per-pane fields:

FieldTypeDefaultNotes
cwdstringnone~ expands server-side; canonicalised and must exist
agentstringnoneclaude, codex, opencode, gemini, … — mutually exclusive with command
commandstringnoneRaw command — mutually exclusive with agent
promptstringnonePrefilled into the agent's input, never auto-submitted
focusboolfalseThis pane gets initial focus
envtablenoneMerged over terminal.env; values may use ${port_offset}
namestringnoneExplicit pane name (handy as a stable selector)
worktreestringnoneGit branch: pane runs in its own worktree under <repo>.worktrees/ — requires cwd
copy_envbooltrueCopy gitignored .env* files from the repo root into the worktree
setupstringnoneCommand run in the worktree before the agent (e.g. "bun install"); failure warns, never blocks
setup_timeout_secsinteger300Timeout for setup
worktree_teardownstring"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:

FieldTypeNotes
idstringRequired, unique, no [/]
needsarrayDependencies; on a foreach step it waits for all instances
foreacharrayFan-out: one instance per item, ${item} substituted; items must be unique
paneinline tableSpawns a pane — same fields as [[panes]] in paneflow up; XOR with send
sendinline table{ target, text, submit? } — requires at least one dependency
readytable{ pattern, timeout_secs? } — regex barrier on the pane's recent output; a timeout must exist here or in [defaults]
capturetable{ var, lines } — last 1–500 lines at the moment ready matched; requires ready
submitboolSubmit 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.sock on Linux, the same path under the user runtime dir on macOS, and the \\.\pipe\paneflow named pipe on Windows. PANEFLOW_SOCKET_PATH is 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 0600 and every connection's peer UID is checked against the server's (SO_PEERCRED/LOCAL_PEERCRED); a mismatch gets JSON-RPC error -32001 before any dispatch. No tokens, no TLS, no network surface.
  • Limits: at most 16 concurrent connections (excess gets -32000 backpressure), 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", …]}, …}
MethodParamsReturns / notes
system.pingLiveness check
system.capabilities{scripting, methods[]} — agent discovery entry point
system.identify{name: "PaneFlow", version, protocol}
workspace.listWorkspaces with indexes and titles
workspace.currentActive workspace
workspace.createname?, cwd?, layout?cwd canonicalised, must be a directory; optional layout tree
workspace.selectindexSwitch workspace
workspace.closeindex?Close a workspace
workspace.upname, layout, panes[]Declarative spawn (used by paneflow up/flow); returns {index, title, panes, surface_ids[]}
workspace.restore_layoutlayoutApply a layout tree to the active workspace
surface.list{surfaces: [{surface_id, name, title, cwd, cmd, workspace}]}
surface.readsurface_id, lines?, offset?{text, lines, total_lines, eof}; lines clamped to 1–4000
surface.searchsurface_id, pattern, max_matches?Substring search; max_matches clamped to 1–1000
surface.renamesurface_id, nameRename a pane
surface.focussurface_idFocus a pane (switches workspace/tab)
surface.send_textsurface_id, text, submit?Gated; 64 KiB cap; submit appends the carriage return
surface.send_keystrokesurface_id, keystrokeGated; refuses submitting keys; CRLF bytes rejected unconditionally
surface.splitdirection, 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_endhook payloadsLifecycle 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:

ToolParamsReturns
list_panesAll panes with surface_id, name, title, cwd, cmd, workspace
read_panetarget (name or id), lines? (default 200, max 4000), offset?Scrollback text
search_panetarget, 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 entry

install 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 entries

Claude 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.

Paneflowの作者 Arthur Jean によって執筆されました。