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? [#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? [#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? [#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? [#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 [#cli-examples]

```bash
# 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? [#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.

```toml
# 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? [#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.

```toml
# 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? [#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).

```bash
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:

```bash
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? [#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:

```bash
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? [#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.

```bash
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.

## Related [#related]

* [Features — headless scripting](/docs/features) for the product-level tour.
* [Configuration schema](/docs/configuration/schema) for `paneflow.json` keys (`terminal.env` interacts with `up`'s per-pane `env`).
* [Keybindings](/docs/keybindings) for the action names available to `shortcuts`.