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.
Finding the socket
Section titled “Finding the socket”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.
if [ -n "${XDG_RUNTIME_DIR:-}" ]; then dir="$XDG_RUNTIME_DIR/ws"else tmp="${TMPDIR:-/tmp}"; dir="${tmp%/}/ws-$(id -u)"; fisock="${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.
Message format
Section titled “Message format”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.
Versions
Section titled “Versions”| 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)Connections and replies
Section titled “Connections and replies”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_closeandworkstream_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 exampleerror: invalid message: …or the version error above. attachswitches the connection to the attach stream.
Reply forms
Section titled “Reply forms”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}]}]}Older servers
Section titled “Older servers”A client should accept both reply styles:
- A server from before version 2 answers every message it knows with plain
ok, includingsplit. Treatokas theokreply. Forsplit, 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 witherror:as anerrorreply whose message is the rest of the line. ThewsCLI turns this into advice to install the new build and restart the server withws kill-serverandws.
Messages
Section titled “Messages”Optional fields may be left out. Fields with a default take that value when left out.
report (v1)
Section titled “report (v1)”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"}}new_workstream (v1)
Section titled “new_workstream (v1)”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 (v1)
Section titled “open (v1)”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"}attach (v1)
Section titled “attach (v1)”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}shutdown (v1)
Section titled “shutdown (v1)”Quit the server and close every pane (ws kill-server). No fields. Reply: ok.
{"v":1,"type":"shutdown"}shutdown_keep (v2)
Section titled “shutdown_keep (v2)”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_tab (v1)
Section titled “open_tab (v1)”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"}project_attach (v1)
Section titled “project_attach (v1)”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"}handoff (v1)
Section titled “handoff (v1)”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 (v1, request)
Section titled “split (v1, request)”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.
workspace_new (v2, request)
Section titled “workspace_new (v2, request)”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}workspace_list (v2, request)
Section titled “workspace_list (v2, request)”List workspaces and their tabs (ws workspace list, ws tab list). No fields. Reply: workspaces.
{"v":2,"type":"workspace_list"}tab_new (v2, request)
Section titled “tab_new (v2, request)”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"]}project_renamed (v2)
Section titled “project_renamed (v2)”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"}pane_send (v2, request)
Section titled “pane_send (v2, request)”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}pane_close (v2, request)
Section titled “pane_close (v2, request)”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}tab_close (v2, request)
Section titled “tab_close (v2, request)”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"}workspace_close (v2, request)
Section titled “workspace_close (v2, request)”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"}workstream_done (v2, request)
Section titled “workstream_done (v2, request)”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_review (v3, request)
Section titled “open_review (v3, request)”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}agent_send (v4, request)
Section titled “agent_send (v4, request)”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_command (v5, request)
Section titled “run_command (v5, request)”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"}workspace_focus (v5, request)
Section titled “workspace_focus (v5, request)”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"}pane_focus (v5, request)
Section titled “pane_focus (v5, request)”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}dashboard (v5, request)
Section titled “dashboard (v5, request)”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"}The attach stream
Section titled “The attach stream”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.
Frames
Section titled “Frames”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.
EVENTpayloads are terminal events (keys, mouse, paste, focus, resize) in the JSON form of the Rustcrosstermcrate’sEventtype. This form follows crossterm and is not yet a stable API.- The server sends the whole screen after an attach. The size comes from
colsandrowsin theattachline and from later resize events. - A client that can’t take output for 5 seconds is dropped.
ExitNotice
Section titled “ExitNotice”| 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.
Environment variables in panes
Section titled “Environment variables in panes”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.
Examples
Section titled “Examples”With nc
Section titled “With nc”Send one request and print the reply. Use a version of nc that supports Unix sockets (-U).
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:
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"}With Python
Section titled “With Python”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"))Related pages
Section titled “Related pages”- Scripting ws: the same operations from the shell.
- Command reference: the commands that send each message.
- Agents: the hooks that send
report. - Sessions: attaching, detaching and restoring.
- Troubleshooting: stale sockets and old servers.