Skip to content

Scripting ws

Anything you set up by hand in ws, a script can set up too. It can create workspaces, open tabs, split panes, start agents and type into them. Each command prints what it made, usually a pane id, so the next command can build on it. This page shows how scripts reach the server and what each command does. It then covers patterns for robust scripts and a few recipes you can copy. For every flag in one place, see the command reference. To talk to the server without the ws binary, see the protocol reference.

A scripting command talks to one ws server over a Unix socket. It picks the server in this order:

  1. -L <name> or --socket-name <name>, or the WS_SOCKET_NAME environment variable. This names a separate server, like tmux -L.
  2. WS_SOCKET. ws sets this in every pane, so a script run inside ws reaches the ws it runs in.
  3. The default server, called default.
Terminal window
ws tab list # inside a pane: this ws; outside: the default server
ws -L dev tab list # the server named "dev"
WS_SOCKET_NAME=dev ws tab list # the same, set once for a whole script

Global flags can go before or after the subcommand. ws -L dev tab list and ws tab list -L dev do the same thing.

The server must already be running. Scripting commands never start one. If nothing answers, the command fails with:

Error: no ws server is running on /…/ws-501/default.sock (start one with `ws`)

Socket files live in $XDG_RUNTIME_DIR/ws/ when that variable is set, and in $TMPDIR/ws-<uid>/ otherwise. The file is <name>.sock. See the protocol page for details.

Command What it does Prints
ws workspace new <name> [--exists-ok] Creates a workspace with one shell tab, in the background nothing
ws workspace list [--json] Lists workspaces and their tab counts a line per workspace, or JSON
ws workspace close <name> Closes a workspace and stops everything in it nothing
ws tab new [options] [-- command…] Opens a tab running a command (default: your shell) the new pane’s id
ws tab list [--workspace <name>] [--json] Lists tabs tab-separated lines, or JSON
ws tab close [--workspace <name>] <tab> Closes a tab by name or number nothing
ws split [options] [-- command…] Splits a pane and runs a command beside it the new pane’s id
ws pane send <pane> [--enter] [text…] Types text into a pane nothing
ws pane close <pane> Closes a pane and stops what runs in it nothing
ws project continue <slug> --workspace … --layout … Opens a project tab with an agent in a chosen layout the tab’s pane ids
ws view --split <file> Opens a rendered Markdown file beside the current pane nothing

ws workspace new creates a workspace with one shell tab. It does not switch the view to it.

Terminal window
ws workspace new Games
ws workspace new Games --exists-ok # succeeds if "Games" already exists

Without --exists-ok, a name that is taken is an error. Names that differ only in case count as the same, so with Games open, ws workspace new games fails, and succeeds without making anything if you add --exists-ok.

ws workspace list prints one line per workspace: its name, (current) for the one on screen, a tab character, and the tab count.

Terminal window
$ ws workspace list
Design System (current) 3 tabs
Games 5 tabs

ws workspace close closes a workspace and stops every pane in it. You can’t close the only workspace this way; use ws kill-server to stop everything.

Terminal window
ws workspace close Games

Other commands that take a workspace name (--workspace) match the exact name first, then the name ignoring case.

ws tab new opens a tab and prints the id of its pane.

Terminal window
ws tab new # a shell in the workspace on screen
ws tab new --workspace Games --name Snake --cwd ~/code/snake -- nvim plan.md
ws tab new --workspace Games --project tetris -- claude
ws tab new --focus -- htop # also switch the view to it
Option Default Meaning
--workspace <name> the workspace on screen Where the tab goes
--name <name> the focused pane’s label The tab’s name
--cwd <dir> the directory you run ws from Where the command starts
--project <slug> none Link the tab to a project. An unknown slug is an error
--focus off Switch the view to the new tab
-- command… your shell What the tab runs

Put the command after -- so its own flags aren’t read as ws flags.

ws tab list prints one line per tab, with five tab-separated fields:

workspace<TAB>tab<TAB>project<TAB>panes<TAB>ports
  • tab is the tab’s name, or the label of its focused pane if it has no name.
  • project is the linked project’s slug, or - when there is none.
  • panes is the tab’s pane ids, separated by commas.
  • ports is the TCP ports the tab’s processes listen on (dev servers), separated by commas, or - when there are none. See Dev servers and ports.
Terminal window
$ ws tab list --workspace Games
Games zsh - 1
Games Tetris tetris 4,5,6
Games Snake - 9,10

--workspace limits the list to one workspace, matched ignoring case. An unknown name is an error.

ws tab close closes a tab and stops its panes. Name the tab by its name (exact, then ignoring case) or by its position, counting from 1. A number that matches a position is read as a position, not a name.

Terminal window
ws tab close --workspace Games Snake
ws tab close 2 # the second tab of the workspace on screen

You can’t close the last tab in ws.

ws split splits a pane, runs a command in the new pane, and prints the new pane’s id.

Terminal window
ws split # inside ws: a shell to the right of this pane
ws split --down -- tail -f log/development.log
agent=$(ws split --pane 4 -- claude)
ws split --pane "$agent" --down --size 24 -- cargo run
Option Default Meaning
--pane <id> $WS_PANE_ID (the pane you run it in) The pane to split. Required outside ws
--down off (split to the right) Put the new pane below instead
--cwd <dir> the directory you run ws from Where the command starts
--size <cells> half The new pane’s columns (right) or rows (--down), as its program sees them
--focus off Switch the view to the new pane
-- command… your shell What the new pane runs

The pane can be in any tab and any workspace. Without --focus, the view stays where it is, so a script can build tabs in the background.

--size counts the cells the program inside sees, not counting the pane’s frame. Use it for programs that need a minimum size, such as a game that needs 24 rows. If the split doesn’t fit, ws split fails with can't split pane <id>: … and starts nothing.

ws pane send types text into a pane as a paste, so the program gets it in one piece. Add --enter to press Enter after it.

Terminal window
ws pane send 12 --enter cargo test
ws pane send "$agent" --enter "Run the tests and fix what fails."
ws pane send 12 --enter # just press Enter
ws pane send 12 "draft text" # type it but don't submit

The words after the pane id are joined with single spaces. Quote the text when you want to keep your own spacing. Only text and Enter can be sent; there is no way to send other keys such as Ctrl-C.

ws pane close closes one pane and stops what runs in it. When it was the last pane in its tab, the tab closes too, and the same goes for an emptied workspace.

Terminal window
ws pane close 12

You can’t close the last pane in ws.

ws project continue opens a tab where an agent picks up a project from its files. With --workspace, --layout or --split, it builds the tab through the same requests as ws tab new and ws split, and prints the new panes.

Terminal window
ws project continue tetris --workspace Games --layout workbench \
--dir ~/code/tetris --run "cargo run" --run-rows 24
Tetris in Games: panes 14 15 16

The layouts are:

Layout Panes, in the printed order
agent the agent
split (or --split) a shell on the left, then the agent on the right
workbench the editor with the brief, plan and log on the left, then the agent top right, then the run pane below the agent

The workbench’s run pane runs the --run command. When the command exits, pressing Enter runs it again and Ctrl-C leaves a shell. Without a run command, it’s a shell. --run-rows starts the run pane with that many rows. ws remembers --dir, --run and --run-rows in the project’s project.toml, so later runs don’t need them.

Other options: --agent (claude or codex, default claude), --model, --task <n> (default: the next open task in plan.md) and --focus. With --workspace, the view stays put unless you pass --focus. Without --workspace, the tab goes in the workspace on screen and the view moves to it.

Without --workspace, --layout or --split, ws project continue opens a single agent tab in the ws you’re in, or starts and attaches ws when run outside it. That form prints Opened a tab continuing <slug>, not pane ids. It also uses and remembers --dir, but it has no run pane, so it ignores --run and --run-rows with a warning.

Inside a ws pane, ws view --split opens a rendered, live-updating Markdown file beside the current pane and focuses it. Add --below to put it underneath. See the Markdown viewer.

Terminal window
ws view --split plan.md
ws view --split --below log.md

Outside ws, ws view shows the file in the current terminal instead.

ws tab new and ws split print exactly one line: the new pane’s id. Capture it with $(…) and pass it to the next command.

Terminal window
pane=$(ws tab new --workspace Games --name Snake --cwd ~/code/snake -- nvim plan.md)
agent=$(ws split --pane "$pane" -- claude) # right of the editor
shell=$(ws split --pane "$agent" --down) # a shell below the agent
ws pane send "$shell" --enter cargo build

Inside a pane, WS_PANE_ID holds that pane’s own id, and WS_WORKSPACE holds the name of its workspace. A script run from a pane can use them to build around itself:

Terminal window
ws split --down --size 10 -- tail -f /tmp/build.log # splits $WS_PANE_ID
ws tab new --workspace "$WS_WORKSPACE" --name scratch

To capture the panes from ws project continue, take everything after panes :

Terminal window
out=$(ws project continue tetris --workspace Games --layout workbench)
read -r editor agent run <<<"${out##*panes }"

The plain output is made for awk -F'\t'. Fields are $1 workspace, $2 tab, $3 project, $4 panes and $5 ports.

Terminal window
# Every tab linked to a project
ws tab list | awk -F'\t' '$3 != "-" { print $1 " / " $2 " → " $3 }'
# The panes of the tab linked to "tetris"
ws tab list --workspace Games | awk -F'\t' '$3 == "tetris" { print $4 }'
# Does Games already have a tab for "tetris"? (exit status 0 if yes)
ws tab list --workspace Games |
awk -F'\t' -v p=tetris '$3 == p { found = 1 } END { exit !found }'

Tab names can contain spaces, which is why the fields are separated by tabs.

ws tab list --json and ws workspace list --json print the same structure: an array of workspaces, each with its tabs. ws tab list --workspace <name> --json keeps only that workspace.

[
{
"name": "Games",
"current": false,
"tabs": [
{
"name": "Tetris",
"project": "tetris",
"panes": [4, 5, 6],
"active": true
}
]
}
]
Field Meaning
name The workspace’s name
current Whether this workspace is on screen
tabs[].name The tab’s name, or its focused pane’s label
tabs[].project The linked project’s slug. Left out when there is none
tabs[].panes The tab’s pane ids
tabs[].active Whether this is the workspace’s active tab

With jq:

Terminal window
ws workspace list --json | jq -r '.[] | select(.current) | .name'
ws tab list --json | jq -r '.[].tabs[] | select(.project == "tetris") | .panes[]'

Every scripting command exits 0 on success. On failure it prints Error: <message> to stderr and exits 1. A usage mistake, such as a missing argument or an unknown flag, exits 2.

The messages say what to do next:

Situation Message
No server running no ws server is running on <socket> (start one with `ws`)
Unknown workspace no workspace "Gmaes" (have: Design System, Games)
Unknown pane no pane 99 (see `ws tab list`)
Unknown tab no tab "Snek" in Games (have: zsh, Tetris, Snake)
Workspace name taken workspace "Games" already exists (--exists-ok to accept that)
Closing the last pane, tab or workspace points you to ws kill-server
ws split outside ws without --pane --pane is needed outside a ws pane
The server is older than the CLI says the server is too old and to install the new build, then ws kill-server and ws
The server didn’t answer within 10 seconds ws didn't answer in time

Use set -euo pipefail so a script stops at the first failure, as scripts/launch-games.sh does.

Scripts that set up a workspace are most useful when you can rerun them at any time. These patterns help:

  • Create workspaces with --exists-ok. It succeeds whether or not the workspace exists.
  • Check before opening a tab. Look for the tab in ws tab list, by project slug (field 3) or by name (field 2), and skip it if it’s there.
  • Tolerate closing what’s gone. Closing an unknown tab or pane is an error. Add || true when “already closed” is fine.
  • Spell workspace names the same way. Commands match workspace names ignoring case, but ws tab list prints each name as it was created. An awk check on field 1 compares exactly.
Terminal window
open_once() { # open_once <workspace> <tab name> -- command…
local ws=$1 name=$2; shift 3
if ws tab list --workspace "$ws" |
awk -F'\t' -v n="$name" '$2 == n { found = 1 } END { exit !found }'; then
return 0
fi
ws tab new --workspace "$ws" --name "$name" -- "$@" >/dev/null
}
ws workspace new Ops --exists-ok
open_once Ops Logs -- tail -f /var/log/system.log
open_once Ops Top -- htop

The repo ships a script that opens a Games workspace with one workbench tab per game. It shows most of the patterns above.

Terminal window
set -euo pipefail
WS=${WS:-ws}
WORKSPACE=${WORKSPACE:-Games}
read -r -a GAMES <<<"${GAMES:-tetris minesweeper breakout terminal-game-of-snake:snake}"
"$WS" workspace new "$WORKSPACE" --exists-ok
for game in "${GAMES[@]}"; do
slug=${game%%:*}
repo=${game#*:}
if "$WS" tab list --workspace "$WORKSPACE" |
awk -F'\t' -v p="$slug" '$3 == p { found = 1 } END { exit !found }'; then
echo "$slug: already open in $WORKSPACE, skipping"
continue
fi
dir="$HOME/code/$repo"
if [ ! -d "$dir" ]; then
echo "$slug: no $dir, skipping" >&2
continue
fi
"$WS" project continue "$slug" --dir "$dir" --workspace "$WORKSPACE" \
--layout workbench --run "${RUN:-cargo run}" --run-rows "${RUN_ROWS:-24}"
done

Step by step:

  1. Settings come from the environment. WS picks the binary, WORKSPACE the workspace, GAMES the list, and RUN and RUN_ROWS the run pane. Each game is a project slug, or slug:repo when the repo folder in ~/code has another name. For tetris, both slug and repo are tetris, because ${game#*:} leaves a string without a colon unchanged.
  2. The workspace is created if needed. --exists-ok makes this safe on a second run.
  3. Games already open are skipped. The awk check looks for a tab whose project column is the slug.
  4. Missing repos are skipped with a warning on stderr, so one missing folder doesn’t stop the rest.
  5. Each game gets a workbench tab: its brief, plan and log in the editor, Claude continuing the project, and cargo run in a run pane 24 rows tall. The tabs open in the background, because --workspace is given without --focus.

Run it against the ws you’re in, or against a separate server:

Terminal window
scripts/launch-games.sh
WS_SOCKET_NAME=dev scripts/launch-games.sh
GAMES="tetris snake" RUN="cargo run --release" scripts/launch-games.sh

It needs a running server, the projects in ~/.local/share/ws/projects/, and the repos in ~/code/<repo>.

A tab per pull request branch: the editor on the left, Claude on the right, and lazygit below Claude.

#!/usr/bin/env bash
set -euo pipefail
ws workspace new Review --exists-ok
review() { # review <tab name> <repo dir>
local name=$1 dir=$2
if ws tab list --workspace Review |
awk -F'\t' -v n="$name" '$2 == n { found = 1 } END { exit !found }'; then
echo "$name: already open"; return
fi
local editor agent
editor=$(ws tab new --workspace Review --name "$name" --cwd "$dir" -- nvim .)
agent=$(ws split --pane "$editor" --cwd "$dir" -- claude)
ws split --pane "$agent" --down --cwd "$dir" -- lazygit >/dev/null
ws pane send "$agent" --enter "Review the changes on this branch against main."
}
review "PR 101" ~/code/design-system
review "PR 102" ~/code/app

Sending the prompt straight away works when the agent reads input that arrives while it starts. If your agent drops early input, pass the prompt on its command line instead, for example -- claude "Review the changes…".

One tab with the dev server on top and its log below, 12 rows tall.

Terminal window
srv=$(ws tab new --name Server --cwd ~/code/app -- bin/rails server)
ws split --pane "$srv" --down --size 12 --cwd ~/code/app -- tail -f log/development.log

Close the tab linked to a project, then open it again. The workbench settings come back from project.toml.

Terminal window
slug=tetris
name=$(ws tab list --workspace Games | awk -F'\t' -v p="$slug" '$3 == p { print $2; exit }')
[ -n "$name" ] && ws tab close --workspace Games "$name"
ws project continue "$slug" --workspace Games --layout workbench

Rerunning scripts/launch-games.sh after the close works too: it reopens only what’s missing.

Find the agent pane in a workbench tab and give it a task. In a workbench tab, the panes from ws project continue come in the order editor, agent, run. Capture them when you create the tab:

Terminal window
out=$(ws project continue tetris --workspace Games --layout workbench)
read -r _ agent run <<<"${out##*panes }"
ws pane send "$agent" --enter "Add a pause key and update plan.md."
ws pane send "$run" --enter # the run pane: run the game again

For a tab you didn’t create in the same script, list its panes with ws tab list and pick by position. The order in ws tab list follows the layout, which may not match creation order, so check it once by hand before relying on it.