Skip to content

Protocol

The ws server listens on a Unix socket. Everything that talks to it uses this socket: the terminal client, agent hooks, and the scripting commands. This page is for people writing their own clients or tools. It covers where the socket is, the JSON-lines messages, how versions work, the replies to requests, and the binary stream a terminal client uses after it attaches. If you only want to drive ws from a shell script, the ws commands in Scripting ws do all of this for you.

The prefix key is Ctrl-Space: prefix d means press Ctrl-Space, let go, then press d. Every key is in Keys and mouse.

The protocol is versioned and meant to be a public API. The server accepts the current version and the one before it, so clients and servers can be upgraded separately.

Sockets live in one directory per user:

Platform Directory
XDG_RUNTIME_DIR is set (most Linux systems) $XDG_RUNTIME_DIR/ws/
Otherwise (macOS, for example) $TMPDIR/ws-<uid>/, or /tmp/ws-<uid>/ without TMPDIR

ws creates the directory with mode 0700 and each socket with mode 0600, so only your user can connect.

Socket Used by
<dir>/default.sock The default background server
<dir>/<name>.sock A named server (ws -L <name>). Characters other than ASCII letters, digits, - and _ become _
<dir>/ws-<pid>.sock A ws --local session, for the life of that process

Inside a pane, WS_SOCKET holds the path of the socket that owns it. Prefer it when it’s set.

Terminal window
if [ -n "${XDG_RUNTIME_DIR:-}" ]; then dir="$XDG_RUNTIME_DIR/ws"
else tmp="${TMPDIR:-/tmp}"; dir="${tmp%/}/ws-$(id -u)"; fi
sock="${WS_SOCKET:-$dir/default.sock}"

A socket file with nothing listening is left over from a crash. The next server to start on that name removes it, and ws doctor --fix removes it too.

Each message is one line of JSON ending in a newline. Every line is an object with two envelope fields, plus the message’s own fields:

Field Type Meaning
v integer The protocol version the message is sent at
type string The message type, in snake_case
{"v":2,"type":"pane_send","pane":12,"text":"cargo test","enter":true}

A line may be up to 1 MiB.

Version Adds
1 report, new_workstream, open, attach, shutdown, open_tab, project_attach, handoff, split
2 workspace_new, workspace_list, tab_new, project_renamed, pane_send, pane_close, tab_close, workspace_close, workstream_done, shutdown_keep; replies with pane ids and errors
3 open_review
4 agent_send
5 run_command, workspace_focus, pane_focus, dashboard

The current server speaks version 5 (VERSION) and accepts versions 1 through 5 (MIN_VERSION to VERSION).

Send each message at the version that introduced it. A split goes out as "v":1 and a tab_new as "v":2. This way a newer client still reaches an older server for everything that server knows. The ws CLI does this.

A line with a version outside the accepted range gets:

error: protocol version 4 not supported (this ws speaks 1..=3)

Connect, write one line, read one line back. The server reads lines on a connection one at a time and answers each before reading the next. If no new line arrives within 2 seconds, it closes the connection. The simplest client sends one message per connection and closes its write side after sending, which is what ws does.

What comes back depends on the message:

  • Requests get a JSON reply line. These are split, workspace_new, workspace_list, tab_new, pane_send, pane_close, tab_close, workspace_close and workstream_done.
  • Other messages get the plain line ok. The server has accepted the message; it acts on it afterwards.
  • A line the server can’t read gets error: <reason>, for example error: invalid message: … or the version error above.
  • attach switches the connection to the attach stream.

Replies use the same envelope. They are always sent at the server’s version.

type Fields Sent for
ok none Requests that succeed and make nothing: workspace_new, pane_send, pane_close, tab_close, workspace_close, workstream_done
pane pane (integer) The pane a split or tab_new created
workspaces workspaces (array of WorkspaceInfo) workspace_list
error message (string) Any request that failed
{"v":2,"type":"ok"}
{"v":2,"type":"pane","pane":14}
{"v":2,"type":"error","message":"no workspace \"Gmaes\" (have: Design System, Games)"}

If the app doesn’t answer a request within 10 seconds, the reply is {"v":2,"type":"error","message":"ws didn't answer in time"}.

WorkspaceInfo

Field Type Meaning
name string The workspace’s name
current boolean Whether it’s the workspace on screen
tabs array of TabInfo Its tabs, in order

TabInfo

Field Type Meaning
name string The tab’s name, or its focused pane’s label
project string, optional The linked project’s slug. Left out when there is none
panes array of integers The tab’s pane ids
active boolean Whether it’s the workspace’s active tab
ports array of integers, optional TCP ports its panes’ processes listen on, sorted. Left out when there are none, and by servers older than this field. See dev servers
{"v":2,"type":"workspaces","workspaces":[{"name":"Games","current":false,"tabs":[{"name":"Tetris","project":"tetris","panes":[4,5,6],"active":true}]}]}

A client should accept both reply styles:

  • A server from before version 2 answers every message it knows with plain ok, including split. Treat ok as the ok reply. For split, that means you don’t learn the new pane’s id.
  • A server that doesn’t know a message answers error: …, usually a version error. Treat a line starting with error: as an error reply whose message is the rest of the line. The ws CLI turns this into advice to install the new build and restart the server with ws kill-server and ws.

Optional fields may be left out. Fields with a default take that value when left out.

An agent lifecycle event from a hook. This is what ws report sends. Reply: ok.

Field Type Default Meaning
pane integer required The pane the agent runs in (WS_PANE_ID)
agent string none Which agent CLI sent it, such as claude or codex
event string required The hook’s event name, such as UserPromptSubmit or Stop
payload any JSON null The hook’s JSON input, passed through as is
{"v":1,"type":"report","pane":3,"agent":"claude","event":"Stop","payload":{"session_id":"abc"}}

Open a workstream from a template (ws new). Reply: ok.

Field Type Default Meaning
template string required Template name
name string required Workstream name
branch string none Existing branch to check out. Without it, a new branch named name
pr integer none Pull request to check out instead
project string none Project slug to link it to
{"v":1,"type":"new_workstream","template":"design-system-pr","name":"pr-101","pr":101}

Open a file in the Neovim of the workstream that pane belongs to (ws open). Reply: ok.

Field Type Default Meaning
pane integer required The pane that asked
location string required path[:line[:col]], with an absolute path
{"v":1,"type":"open","pane":7,"location":"/code/app/models/user.rb:42"}

A terminal client attaching (ws, ws attach). After this line, the connection carries the attach stream, not JSON lines.

Field Type Default Meaning
cols integer required The client terminal’s width
rows integer required The client terminal’s height
build string none The client binary’s build id. If it differs from the server’s, the server shows a notice suggesting a restart
open OpenTab object none A tab to open on attach (see open_tab)
{"v":1,"type":"attach","cols":120,"rows":40}

Quit the server and close every pane (ws kill-server). No fields. Reply: ok.

{"v":1,"type":"shutdown"}

Quit the server like shutdown, but save the session first and leave it on disk, so the next server can restore it (ws kill-server --keep, ws restart). The server saves before it stops any pane. No fields. Reply: ok.

{"v":2,"type":"shutdown_keep"}

Open a tab in the current workspace running command in cwd. Reply: ok.

Field Type Default Meaning
cwd string required Where the command starts
command array of strings [] The program and its arguments. Empty runs the user’s shell
name string none The tab’s name
project string none The project slug the tab works on
{"v":1,"type":"open_tab","cwd":"/code/app","command":["claude"],"name":"Review"}

Link the tab holding pane to a project (ws project attach). Reply: ok.

Field Type Default Meaning
pane integer required A pane in the tab to link
slug string required The project’s slug
{"v":1,"type":"project_attach","pane":3,"slug":"button-a11y"}

Ask a project’s running agents for their handoff entries (ws project handoff). Reply: ok.

Field Type Default Meaning
slug string required The project’s slug
{"v":1,"type":"handoff","slug":"button-a11y"}

Split pane and run command beside it (ws split, ws view --split). Reply: pane with the new pane’s id, or error.

Field Type Default Meaning
pane integer required The pane to split. It can be in any tab or workspace
right boolean true true puts the new pane to the right, false below
cwd string required Where the command starts
command array of strings required The program and its arguments. Empty runs the user’s shell
focus boolean true Show and focus the new pane. Scripts pass false to build tabs in the background
size integer none (half) The new pane’s size in the split’s direction, in cells its program sees: columns to the right, rows below. Older servers ignore it
{"v":1,"type":"split","pane":4,"right":false,"cwd":"/code/snake","command":["cargo","run"],"focus":false,"size":24}

Note that focus defaults to true here, while ws split sends false unless you pass --focus.

Create a workspace with one shell tab, in the background (ws workspace new). Reply: ok, or error if the name is taken (ignoring case) and exists_ok isn’t set.

Field Type Default Meaning
name string required The workspace’s name
exists_ok boolean false Succeed if a workspace with this name exists. Names are compared ignoring case
{"v":2,"type":"workspace_new","name":"Games","exists_ok":true}

List workspaces and their tabs (ws workspace list, ws tab list). No fields. Reply: workspaces.

{"v":2,"type":"workspace_list"}

Open a tab in a workspace (ws tab new). Reply: pane with the tab’s pane id, or error.

Field Type Default Meaning
workspace string the workspace on screen Workspace name, exact then ignoring case
name string none The tab’s name
cwd string none Where the command starts. The ws CLI always sends it
project string none Project slug to link the tab to. An unknown slug is an error
command array of strings [] The program and its arguments. Empty runs the user’s shell
focus boolean false Switch the view to the new tab
{"v":2,"type":"tab_new","workspace":"Games","name":"Tetris","cwd":"/code/tetris","project":"tetris","command":["nvim"]}

A project’s slug changed (ws project rename --slug). Tabs linked to from now link to to. Reply: ok. This is not a request.

Field Type Default Meaning
from string required The old slug
to string required The new slug
{"v":2,"type":"project_renamed","from":"a11y","to":"buttons"}

Type text into a pane as a paste, then press Enter if asked (ws pane send). Reply: ok or error.

Field Type Default Meaning
pane integer required The pane id
text string required The text. May be empty
enter boolean false Press Enter after the text
{"v":2,"type":"pane_send","pane":4,"text":"cargo test","enter":true}

Close a pane and stop what runs in it (ws pane close). An emptied tab or workspace closes too. Reply: ok or error. Closing the last pane in ws is an error.

Field Type Default Meaning
pane integer required The pane id
{"v":2,"type":"pane_close","pane":4}

Close a tab by name or 1-based number (ws tab close). Reply: ok or error. Closing the last tab in ws is an error.

Field Type Default Meaning
workspace string the workspace on screen The workspace the tab is in
tab string required The tab’s name, or its number counting from 1
{"v":2,"type":"tab_close","workspace":"Games","tab":"Tetris"}

Close a workspace and everything in it (ws workspace close). Reply: ok or error. Closing the only workspace is an error.

Field Type Default Meaning
name string required The workspace’s name
{"v":2,"type":"workspace_close","name":"Games"}

Finish a workstream: close its tab and remove its git worktree (ws done). Give name or pane. Reply: ok once the tab is closed (the worktree is removed a moment later), or error saying why not: uncommitted changes, commits not on any remote branch, not a workstream, or the last tab in ws. See ws done.

Field Type Default Meaning
name string none The workstream’s name
pane integer none A pane in the workstream’s tab, used when name is missing
force boolean false Remove it even with uncommitted or unpushed work

When pane is in the tab being closed, the connection can end before the reply arrives. A client that asked from inside the workstream should treat that as success.

{"v":2,"type":"workstream_done","name":"pr-101"}

Open a review tab (ws review). The client has already made the worktree and written .ws/pr.md; the server lays out the template’s panes in the review workspace, starting its agent. Reply: pane (the review agent’s pane) or error. If a tab for the same name is open and fresh is false, the server just goes to it (when focus) and replies with its focused pane. See Reviewing PRs.

Field Type Default Meaning
workspace string required Workspace for the tab, made if missing
title string required The tab’s name, e.g. billing#12 Invoice full width
name string required Workstream name that identifies the review, e.g. review-billing-12
template string required Template that lays out the tab; review falls back to the built-in one
repo string required The main clone
dir string required The review’s worktree, where panes start
branch string none The PR’s branch, for PR status
vars array of [name, value] [] Template placeholders: pr, url, context, review_command, …
fresh boolean false Replace an open tab for the same name, so the agent starts over
focus boolean true Switch to the tab
{"v":3,"type":"open_review","workspace":"PR Review","title":"billing#12 Invoice","name":"review-billing-12","template":"review","repo":"/code/billing","dir":"/wt/billing/review-12","branch":"invoice","vars":[["pr","12"],["review_command","claude '/review-pr …'"]],"fresh":false,"focus":true}

Type text into the agent in the tab holding pane, as a paste, then press Enter (ws pr comments). The agent is the tab’s focused pane if it’s an agent, else the tab’s first agent; pane itself never counts. Reply: pane (the agent’s pane), or error when there’s no such pane or the tab has no agent.

Field Type Default Meaning
pane integer required A pane in the tab (usually the caller’s own, $WS_PANE_ID)
text string required What to type
{"v":4,"type":"agent_send","pane":7,"text":"Address these unresolved review comments. …"}

Run a keymap command by its keys.toml name, as if its key had been pressed (the Dashboard’s buttons). Commands that ask something, like new-tab or new-workstream, open their prompt on the server’s screen. Reply: ok, or error for a name that isn’t a command (ws keys --list lists them).

Field Type Default Meaning
name string required The command: new-tab, new-workstream, git-panel, …
{"v":5,"type":"run_command","name":"new-workstream"}

Switch to a workspace, by name (exact, else ignoring case). Reply: ok, or error when there’s no such workspace.

Field Type Default Meaning
name string required The workspace
{"v":5,"type":"workspace_focus","name":"Games"}

Switch to a pane: its workspace, its tab and the pane. Reply: ok, or error when there’s no such pane.

Field Type Default Meaning
pane integer required The pane
{"v":5,"type":"pane_focus","pane":12}

What’s happening, for the Dashboard. Reply: dashboard, with agents (most urgent first), workstreams and ports (by port number; older servers leave ports out):

Field Type Meaning
agents[].pane integer The agent’s pane
agents[].workspace string Its workspace
agents[].label string The pane as the sidebar names it
agents[].status string blocked, working, done or idle
agents[].detail string, optional What it’s doing or waiting for
agents[].secs integer Seconds in this status
workstreams[].name string The workstream
workstreams[].workspace string Its workspace
workstreams[].pane integer Its tab’s focused pane, to go to it
workstreams[].branch string, optional Its branch
workstreams[].pr string, optional Its PR as the top bar shows it, e.g. #101 ✓ approved
ports[].port integer A TCP port something in a pane listens on
ports[].pane integer That pane, to go to it
ports[].workspace string Its workspace
ports[].tab string Its tab, as the tab bar names it
ports[].command string What’s listening: its command line, or the pane’s command
{"v":5,"type":"dashboard"}

A terminal client sends an attach line, then the connection changes to binary frames in both directions. The server runs the whole interface and sends terminal output. The client puts its terminal in raw mode, writes that output to its screen, and sends input events back. This is how tmux works: with the interface in the server, detaching and reattaching need nothing from the client but a terminal.

If the server can’t accept the attach line, it answers with a text line starting with e (error: …) instead of a frame. A client can check the first byte it reads: e means an error line, anything else is the start of a frame.

Each frame is a 5-byte header followed by the payload:

[kind: u8][len: u32, big-endian][payload: len bytes]
Kind Name Direction Payload
0 OUTPUT server to client Terminal output bytes. Write them to the terminal as they are
1 EXIT server to client A JSON ExitNotice. The server closes the connection after it
2 EVENT client to server A JSON input event
  • A frame’s payload may be up to 64 MiB.
  • Both sides ignore frames of kinds they don’t know.
  • EVENT payloads are terminal events (keys, mouse, paste, focus, resize) in the JSON form of the Rust crossterm crate’s Event type. This form follows crossterm and is not yet a stable API.
  • The server sends the whole screen after an attach. The size comes from cols and rows in the attach line and from later resize events.
  • A client that can’t take output for 5 seconds is dropped.
Field Type Meaning
reason string detached, replaced or quit
message string, optional Something to show the user after restoring the terminal
Reason When
detached The user pressed prefix d. The server keeps running
replaced Another client attached. Only one client is attached at a time
quit The server quit: prefix q, ws kill-server (with or without --keep), ws restart, or the last pane exited
{"reason":"replaced","message":"attached from another terminal"}

If the connection ends without an EXIT frame, treat it as quit: the server went away.

ws sets these in the environment of every program it starts in a pane. They let a program, hook or script find its way back to the server.

Variable Set in Value
WS_SOCKET every pane The socket path of the ws that owns the pane
WS_PANE_ID every pane The pane’s id, as used in pane fields
WS_WORKSPACE every pane The name of the workspace the pane was started in
WS_WORKSTREAM panes started from a workstream template The workstream’s name

A hook, for example, reads WS_SOCKET and WS_PANE_ID and sends a report for its own pane. A program that finds no WS_SOCKET is not running inside ws.

Send one request and print the reply. Use a version of nc that supports Unix sockets (-U).

Terminal window
printf '%s\n' '{"v":2,"type":"workspace_list"}' | nc -U "$sock"
{"v":2,"type":"workspaces","workspaces":[{"name":"main","current":true,"tabs":[{"name":"zsh","panes":[1],"active":true}]}]}

Some nc versions keep the connection open after stdin ends. In that case the server closes it after its 2-second wait for another line, so the command may take up to 2 seconds to return.

Open a tab, then type into it:

Terminal window
printf '%s\n' '{"v":2,"type":"tab_new","workspace":"main","name":"Build","cwd":"/tmp","command":[]}' | nc -U "$sock"
# {"v":2,"type":"pane","pane":7}
printf '%s\n' '{"v":2,"type":"pane_send","pane":7,"text":"uname -a","enter":true}' | nc -U "$sock"
# {"v":2,"type":"ok"}

A small client that sends one message per connection and handles both reply styles:

import json, os, socket
def socket_path(name="default"):
if os.environ.get("WS_SOCKET") and name == "default":
return os.environ["WS_SOCKET"]
if os.environ.get("XDG_RUNTIME_DIR"):
base = os.path.join(os.environ["XDG_RUNTIME_DIR"], "ws")
else:
base = os.path.join(os.environ.get("TMPDIR", "/tmp"), f"ws-{os.getuid()}")
return os.path.join(base, f"{name}.sock")
def send(message, version):
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(10)
s.connect(socket_path())
s.sendall((json.dumps({"v": version, **message}) + "\n").encode())
s.shutdown(socket.SHUT_WR)
line = s.makefile().readline().rstrip("\n")
if line == "ok":
return {"type": "ok"}
if line.startswith("error: "):
return {"type": "error", "message": line[len("error: "):]}
return json.loads(line)
reply = send({"type": "workspace_list"}, version=2)
for ws in reply["workspaces"]:
for tab in ws["tabs"]:
print(ws["name"], tab["name"], tab.get("project", "-"), tab["panes"])
reply = send({"type": "split", "pane": 1, "right": False, "cwd": "/tmp",
"command": ["top"], "focus": False, "size": 10}, version=1)
if reply["type"] == "error":
raise SystemExit(reply["message"])
print("new pane", reply.get("pane"))