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.
How scripts find the server
Section titled “How scripts find the server”A scripting command talks to one ws server over a Unix socket. It picks the server in this order:
-L <name>or--socket-name <name>, or theWS_SOCKET_NAMEenvironment variable. This names a separate server, liketmux -L.WS_SOCKET. ws sets this in every pane, so a script run inside ws reaches the ws it runs in.- The default server, called
default.
ws tab list # inside a pane: this ws; outside: the default serverws -L dev tab list # the server named "dev"WS_SOCKET_NAME=dev ws tab list # the same, set once for a whole scriptGlobal 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.
The commands
Section titled “The commands”| 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 |
Workspaces
Section titled “Workspaces”ws workspace new creates a workspace with one shell tab. It does not switch the view to it.
ws workspace new Gamesws workspace new Games --exists-ok # succeeds if "Games" already existsWithout --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.
$ ws workspace listDesign System (current) 3 tabsGames 5 tabsws 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.
ws workspace close GamesOther 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.
ws tab new # a shell in the workspace on screenws tab new --workspace Games --name Snake --cwd ~/code/snake -- nvim plan.mdws tab new --workspace Games --project tetris -- claudews 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>portstabis the tab’s name, or the label of its focused pane if it has no name.projectis the linked project’s slug, or-when there is none.panesis the tab’s pane ids, separated by commas.portsis the TCP ports the tab’s processes listen on (dev servers), separated by commas, or-when there are none. See Dev servers and ports.
$ ws tab list --workspace GamesGames zsh - 1Games Tetris tetris 4,5,6Games 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.
ws tab close --workspace Games Snakews tab close 2 # the second tab of the workspace on screenYou can’t close the last tab in ws.
Splitting panes
Section titled “Splitting panes”ws split splits a pane, runs a command in the new pane, and prints the new pane’s id.
ws split # inside ws: a shell to the right of this panews split --down -- tail -f log/development.logagent=$(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.
Sending text to a pane
Section titled “Sending text to a pane”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.
ws pane send 12 --enter cargo testws pane send "$agent" --enter "Run the tests and fix what fails."ws pane send 12 --enter # just press Enterws pane send 12 "draft text" # type it but don't submitThe 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.
Closing panes
Section titled “Closing panes”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.
ws pane close 12You can’t close the last pane in ws.
Project tabs with a layout
Section titled “Project tabs with a layout”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.
ws project continue tetris --workspace Games --layout workbench \ --dir ~/code/tetris --run "cargo run" --run-rows 24Tetris in Games: panes 14 15 16The 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.
Markdown beside a pane
Section titled “Markdown beside a pane”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.
ws view --split plan.mdws view --split --below log.mdOutside ws, ws view shows the file in the current terminal instead.
Capturing pane ids and chaining
Section titled “Capturing pane ids and chaining”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.
pane=$(ws tab new --workspace Games --name Snake --cwd ~/code/snake -- nvim plan.md)agent=$(ws split --pane "$pane" -- claude) # right of the editorshell=$(ws split --pane "$agent" --down) # a shell below the agentws pane send "$shell" --enter cargo buildInside 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:
ws split --down --size 10 -- tail -f /tmp/build.log # splits $WS_PANE_IDws tab new --workspace "$WS_WORKSPACE" --name scratchTo capture the panes from ws project continue, take everything after panes :
out=$(ws project continue tetris --workspace Games --layout workbench)read -r editor agent run <<<"${out##*panes }"Reading ws tab list
Section titled “Reading ws tab list”The plain output is made for awk -F'\t'. Fields are $1 workspace, $2 tab, $3 project, $4 panes and $5 ports.
# Every tab linked to a projectws 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:
ws workspace list --json | jq -r '.[] | select(.current) | .name'ws tab list --json | jq -r '.[].tabs[] | select(.project == "tetris") | .panes[]'Errors and exit codes
Section titled “Errors and exit codes”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.
Making scripts safe to run twice
Section titled “Making scripts safe to run twice”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
|| truewhen “already closed” is fine. - Spell workspace names the same way. Commands match workspace names ignoring case, but
ws tab listprints each name as it was created. Anawkcheck on field 1 compares exactly.
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-okopen_once Ops Logs -- tail -f /var/log/system.logopen_once Ops Top -- htopWalk-through: scripts/launch-games.sh
Section titled “Walk-through: scripts/launch-games.sh”The repo ships a script that opens a Games workspace with one workbench tab per game. It shows most of the patterns above.
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}"doneStep by step:
- Settings come from the environment.
WSpicks the binary,WORKSPACEthe workspace,GAMESthe list, andRUNandRUN_ROWSthe run pane. Each game is a project slug, orslug:repowhen the repo folder in~/codehas another name. Fortetris, bothslugandrepoaretetris, because${game#*:}leaves a string without a colon unchanged. - The workspace is created if needed.
--exists-okmakes this safe on a second run. - Games already open are skipped. The
awkcheck looks for a tab whose project column is the slug. - Missing repos are skipped with a warning on stderr, so one missing folder doesn’t stop the rest.
- Each game gets a workbench tab: its brief, plan and log in the editor, Claude continuing the project, and
cargo runin a run pane 24 rows tall. The tabs open in the background, because--workspaceis given without--focus.
Run it against the ws you’re in, or against a separate server:
scripts/launch-games.shWS_SOCKET_NAME=dev scripts/launch-games.shGAMES="tetris snake" RUN="cargo run --release" scripts/launch-games.shIt needs a running server, the projects in ~/.local/share/ws/projects/, and the repos in ~/code/<repo>.
Recipes
Section titled “Recipes”A review workspace
Section titled “A review workspace”A tab per pull request branch: the editor on the left, Claude on the right, and lazygit below Claude.
#!/usr/bin/env bashset -euo pipefailws 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-systemreview "PR 102" ~/code/appSending 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…".
A server and its logs
Section titled “A server and its logs”One tab with the dev server on top and its log below, 12 rows tall.
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.logRestart one game tab
Section titled “Restart one game tab”Close the tab linked to a project, then open it again. The workbench settings come back from project.toml.
slug=tetrisname=$(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 workbenchRerunning scripts/launch-games.sh after the close works too: it reopens only what’s missing.
Send a command to an agent
Section titled “Send a command to an agent”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:
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 againFor 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.
Related pages
Section titled “Related pages”- Command reference: every command and flag.
- Protocol: the socket messages behind these commands.
- Projects: what
ws project continueresumes. - Panes and layouts: splitting and resizing by hand.
- Concepts: workspaces, tabs, panes and servers.