Exit codes and the contract
Every verb answers the same way on the command line and as an MCP tool. The exit code says which kind of failure it was, and with --json the answer is JSON alone.
The command line, the MCP tools and the wsp skill are one contract: every capability exists in all three or in none, and the parity test in the repo holds them to it. With --json, stdout carries JSON alone: one JSON object per line, frames first, the last line is the result; a verb with no stream prints the result alone. The result is the object the MCP tool of the same name answers with, less what the frames already carried, so no line prints twice: wsp exec --json prints one exec.output frame per output line and then a result with the exit code and the folder; wsp add root@203.0.113.7 --recipe mine --json prints a {"setup": ...} frame per step line and a {"waiting": ...} frame per sign-in waiting on the person, then {"computer": ...}. Without --json, stdout is for a person. Progress, the waking line and every refusal go to stderr. A refusal or a failure is one line on stderr, the failure object {"error": "<the line>", "class": "usage", "exit": 3} under --json, and the exit code is the class's:
| Exit | Class | When |
|---|---|---|
| 0 | ok | it did what its line says; with --json stdout holds the answer |
| 1 | provider | the host, the runtime, a provider or the machine refused or failed |
| 2 | auth | no key, no sign-in, or the host refused the token |
| 3 | usage | the line was refused before anything ran: a missing argument, an unknown flag or a value nothing takes |
wsp exec is the one exception: its exit code is the command's own, and only a machine that could not run it is a provider failure. A person saying no to a confirmation is <name> kept on stderr with exit code 1. Every line that takes something away (wsp remove, wsp leave, wsp delete, wsp discard, wsp projects remove, wsp recipes remove, wsp skills remove, wsp servers remove, wsp worktree remove, wsp thread forget) asks that one question only once the host's own refusals pass, and off a terminal is refused without --yes. An MCP tool answers a failure as a tool error whose structured content is the same object, so class reads the same on both doors; a call whose inputs do not fit the tool's schema is refused by the server before the tool runs.
run
wsp projects # every project, each on its computer; the name is what wsp run takes
wsp run spoo --notify me "Read ticket 308 ... and build it."
wsp run spoo --branch ticket/308 --notify me "Build ticket 308 on its own branch."
wsp run spoo --model claude-sonnet-5 --effort low --access ask "Review the branch ticket/308."
wsp run --agent codex "Say in one line which folder you are in and what is in it."
wsp run <project> opens a thread in the project's folder on this computer: it runs the agent already on this computer's PATH, keeps its session in the person's own agent folder, and nothing is copied, forked or woken for it; several threads share the folder as panes in one terminal do. --branch <branch> runs it in a worktree of the project's repo on that branch instead: the one git already has the branch checked out in, wherever it is, else one wsp makes under its own folder, a new branch starting from the project folder's current commit, with .env files carried in. What else it gets is decided per ecosystem by the lockfiles the branch tracks, each in the folder that holds it and only where the project folder holds that ecosystem's dependency or build folder beside it, or at the top for an ecosystem that carries nothing (uv, Poetry, Cargo, Go), whose environment may sit outside the project, so a lockfile a test fixture tracks costs nothing: relocatable dependency folders (node_modules for npm, pnpm, Yarn and Bun, Yarn's .yarn/cache and .yarn/unplugged, Composer's vendor) are carried in, by a clone where the disk shares blocks and on Linux an overlay of a frozen copy of the folder's own where it does not, made once per install and kept for the next worktree, so a reinstall in the project folder never reaches a worktree; folders that hold the path they were built at (a virtualenv, target, .next) never are; caches outside the project (Cargo's registry, Go's module cache, uv's) are shared as they stand. Each ecosystem's locked install then runs once in the folder holding its lockfile (uv sync --locked, poetry install, cargo fetch --locked, go list -mod=readonly -deps -test ./... at the top, npm install --no-save, pnpm install --frozen-lockfile, yarn install --frozen-lockfile, bun install --frozen-lockfile, composer install), none of which rewrites a tracked lockfile, skipped for pnpm where the node_modules carried in were installed from the branch's own lockfile, and the project's after-worktree command after them at the top, before the thread starts. --cwd starts it in a folder inside the project or one of its worktrees and nowhere else. With no project named, from a thread it runs beside you in your own folder, and from a terminal inside one of your project folders it goes to that project; the first line printed says where: thread 1a2b3c4d on spoo in ~/spoo. Outside every project it is refused in one line and nothing starts. A project on a box runs in its folder there the same way, as the login the box was added with, and a thread there with no project named runs beside you on that box; a thread on a box naming a project on another computer is refused for now, and told to send the lead of its tree a message where that lead can start it, and that the person can start it there otherwise. From a thread, that project is your own or, from a thread on this computer, a box project of your own repository, run in its folder there, and the child joins your tree wherever it runs: wsp threads lists it, send reaches it and --notify me wakes you at its end. A child on a box sends to you in turn and lists your tree, its words alone with --detach, and reaches nothing else of yours: no stop, steer, rename or read of a thread on another computer, and any verb it names on your folder or on a thread of your tree there, an exec or a commit, is refused with a message to you as the road. A project of another repository and another folder the person keeps of yours on this computer are each refused by that rule. Exec takes a thread of your tree, so it reaches none of these. --agent names the agent to run in the thread; absent, the project's agent runs (wsp projects set), else the default agent (wsp agents default), else claude, and a thread runs only on an agent the host has an adapter for; a refusal names the ones it has. --model and --effort pick the model and the reasoning effort by the agent's own words (claude-sonnet-5; low, medium, high, xhigh, max), the same lists the app's composer offers. --access takes wsp's own word, the same on every agent: ask (asks about each action that needs permission), auto-edit (edits files without asking), full (every action without asking) or plan (reads and proposes, changes nothing); an agent with no mode for the word refuses it naming the ones it takes, and an agent's own spelling is refused. Absent, the project's, then the agent's defaults set with wsp agents set, then the catalog's run (high for claude), with full access, and a value the catalog does not list is refused before anything starts, naming the list. A cheaper model for a review is the usual pick. A thread's folder decides which project state (sessions, memory, CLAUDE.md) the agent loads, so put a thread where its project is. --title names the thread, as a person naming it does: the name shows in the sidebar and in the agent's own session list at once, and the title the host asks the agent for as the turn starts never replaces it. Without one the thread is titled by its opening words for the few seconds the agent takes to name it, and by that name after. --notify me names the caller: the thread you are when the host launched you as one, and the person when it did not, which is how a thread starts a child without knowing its own id. --notify <thread> names another thread outright, one of your own tree: the lead that started you, a thread beside you under it, or one you started. From a box it names one of those on that box alone, and your lead hears your end through the --notify me it started you with; any other thread is refused as no thread to notify, and that work goes through the person. Either way the line goes into that thread as a message (steered into its running turn or queued), and a line the person gets shows in their app. The notify line goes once, at the reply; a reply given with background tasks still running says so, and a second line goes at the turn's end. The person's line reads thread 1a2b3c4d finished (completed, 12m 4s, $0.41): <last line of the reply>; a thread's carries the same facts and then the child's final message whole, so you act on the report without reading anything else. A --notify me from a turn that has ended, or with a token this host never launched, is refused rather than sent to the person.
--notify repeats, and each target gets the line once: --notify me --notify 5e6f7a8b wires a builder's end to you and to a reviewer thread together, and the reviewer's own end back to the builder and to you. That removes the relay you would do by hand; it decides nothing, so you still read every report and make the calls. A target whose thread is gone by the time the child ends falls back to the person, and the new thread's own id as a target is refused.
The command line streams the reply to stderr as it arrives and prints it once: into a pipe, which is what you have, stdout carries the whole reply at the end, and on a person's terminal the streamed copy is the reply and stdout adds no second one; a failed turn is a provider failure, its reason the one line on stderr. With --detach (detach true on the tool) nothing streams: the thread id is the whole answer, the turn runs on, and its end goes to whoever --notify named (see the notify paragraph above). A turn ends when the agent process exits, not at its reply, and the thread reads running until then, which can be minutes when the agent left a command running; what a send before then does is under send. When the host restarts under the turn (wsp restart, a service restart, the app relaunching), the command line says so on stderr, dials it again for as long as a host is given to start and prints the reply whole on stdout once it lands, since the stream on the screen has a gap; the tools wait the same way. A host that does not come back in that time is the command's failure in the dial's own words, and the turn goes on. The first stdout line is thread <id>, the id send takes. The MCP tool returns the reply text with the thread id, its record's id, agent and outcome. When the thread's previous turn did not finish (a deadline, a host restart, a nap), the reply text from send and run opens with the line previous turn was cut; resuming and the structured output carries afterCut: true, and the command line prints that line on stderr before the reply; the agent then resumes a transcript that may be missing its last steps, so restate what matters.
send
wsp send 1a2b3c4d "Also cover the codex case in the test."
wsp send 1a2b3c4d --model claude-opus-5-5 --effort high "Now the hard part."
A send is never refused for meeting a turn. Into a thread whose turn is not running the message starts a new turn (outcome started), on the model, effort and access named or the thread's own. Into a thread whose turn is running: when the agent can take input mid-turn (Claude Code does) the message joins the running turn (outcome steered) and arrives at that turn's next tool round, the way a person's message does, and the reply is that turn's, on that turn's picks, with its images where the agent's steer takes them (Claude Code's does); otherwise the message waits for the running turn to end and then runs (outcome queued), and so does one with a --file that is not an image, or with an image to an agent whose steer takes none. A reply the agent gave while a command it started was still running is not a reply yet: that turn is still running and the message steers it. Into a thread whose turn has replied and is waiting only on its agent process to exit, the message waits for that process and runs as the thread's next turn (outcome queued), which the host says as This thread replied, still working; the message runs as its next turn once that process exits; the app's composer shows the same words, and threads reads the thread running until the agent process exits. Two sends keep the order they arrived in. A start on a computer already running as many threads as its threads at once waits for a slot and starts on its own when one frees (outcome held), with capped beside it, the computer and what runs there against its number; the command line says waiting while <computer> is running <n> of <m> threads. A thread that follows a run or a send to its end, without --detach, lends that thread's next turn its own slot, one turn at a time, a turn already held ahead of the message included, so a send into your lead or a thread beside you never waits on your own slot; every other start waits for a slot of its own. A thread whose follow gives up before the turn it follows ends, or that is stopped while that turn runs, works again beside it, and each then holds a slot of its own: the computer runs one past its number until either ends, and nothing new starts there meanwhile. The command line says which outcome on stderr. A person's message on the same thread lands in order with yours. Never start a second thread to hurry a running one.
Threads talk to each other, and this is the road: wsp send <thread> "<message>" (the send tool) puts your words into another thread by its id, one of your own tree, the lead that started you, a thread beside you under it or one you started, wherever it runs, and wsp threads (the threads tool) is where you find that id, with the project, the folder, its branch, the agent and the title beside it. A thread outside your tree, the person's own beside you included, is not yours to reach: threads leaves it out, a send, a stop or a rename naming it reads as no such thread, and its transcript is not in what thread read answers, so a job that needs another tree's thread goes to the person. A stop cascades: stopping your lead stops every thread under it, your siblings and you among them. The other thread's answer does not come back to this call: it comes back as its own next message when that thread was started with --notify me naming you, or with --notify <your thread>. So a thread asking another thread something starts it with a notify that names the asker, sends, and ends its turn.
thread read
wsp thread read 1a2b3c4d
wsp thread read 1a2b3c4d --last
The thread's messages as the app lists them, oldest first, one block each: who it is and the clock on the first line, the text under it. person is the message that opened or steered a turn, agent is the agent's own words, tool is one call of its folded to the line the app's row reads, and turn is the outcome, the duration and the cost the turn ended with, and the reason where it did not complete. --last prints the final reply alone, the whole message the thread's finished line carries, which is what to read when the line that reached you is shorter than the report behind it. When the thread has started another turn since, a second row says so, so the report you are reading is never taken for the one being written. Under --json and on the tool the answer is threadId and messages, each one {who, at, text} with at the ms epoch the runtime recorded. The transcript is the host's own, so a read touches no machine and a paused machine's thread reads the same as a running one's. A call's output and the agent's reasoning are no rows of it, and a thread whose rows the transcript's cap has dropped answers with none, which is an answer and not an error.
thread head
wsp thread head 1a2b3c4d
The thread's title, the agent, model and access it runs on, where it stands and its folder, then its newest events as thread read lists them, as many as fit in 64 KB. Under --json and on the tool the answer is facts (the thread as threads lists it, with model, effort, contextWindow and, while a turn runs, its turnId), events (the newest transcript events, oldest first, each tool result past 2 KB cut with cut set to its whole length), pos (the newest position the transcript has issued; every event carries its own pos) and total (how many events of the thread the transcript holds). Use it to see where a thread stands without reading all of it. A head marks nothing read.
thread allow, thread deny
wsp thread allow 1a2b3c4d
wsp thread deny 1a2b3c4d
wsp thread deny 1a2b3c4d --reason "edit the test file, not the fixture"
A thread whose agent asked to run a command or write a file is stopped on that question: it reads Needs you in wsp threads, runs nothing and ends nothing until somebody answers. A terminal watching the turn prints the question and the keys that answer it; from anywhere else these two lines answer it by thread id, the same pick the app's buttons send. wsp thread read <thread> shows what is being asked. A deny takes --reason, what the agent should do instead: Claude Code reads it with the refusal, and Codex, whose refusal carries no words, gets it as the person's next message in the same turn. Both print what they closed, and both are refused in one line when the thread is waiting on no prompt, so answering twice is never mistaken for answering once.
threads wait
wsp run dev --detach --notify me --title "413 build" "Read ticket 413 ... and build it."
wsp threads wait 1a2b3c4d 5e6f7a8b --timeout 600
--detach on run and send (detach true on both tools) prints thread <id> the moment the turn is started and returns; the turn runs on. threads wait then blocks until one of the named threads leaves running and prints that thread's finished line, thread 1a2b3c4d finished (completed, 12m 4s, $0.41), with the agent's reply whole under it, since a reply's last line is as often a code fence as an answer; --tail prints the last line alone instead, thread 1a2b3c4d finished (completed, 12m 4s, $0.41): <last line of the reply>, which is the line a --notify sends to the person. Under --json and on the tool it is the id, status, duration, cost and the reply's last line under finished either way. One thread per call: a script that started three builders calls it three times, dropping each returned id from the list, since a thread already over comes back at once and would come back again. --timeout <s> (timeout, seconds) gives up after that long: nothing on stdout, thread 1a2b3c4d still running after 10m on stderr (timedOut true on the tool) and the ok exit code, since nothing failed, so other work fits between calls. A host that restarts under the wait is dialled again for as long as a host is given to start and the wait carries on, --timeout still counting from the start. This is a shell script's verb: a script has nothing else to do while its builders run, and a person is reading its output. Do not call it in your own conversation, where it spends minutes of a turn on nothing and nothing can reach you meanwhile. Never poll threads for a state change either: a thread starts its children with --notify me, and a caller that is not a thread hands more than one turn's work to a coordinator thread.
stop
wsp stop 1a2b3c4d
Ends the thread's running turn through the runtime, the way the app's stop button does; the turn ends with status interrupted and whoever followed it gets turn interrupted. The line says thread <id> stopped, or thread <id> not running when the turn had already ended; both succeed, since neither is an error. Nothing is paused or killed and the thread takes the next send. Use it when a send started a turn you did not mean to.
wsp stop 1a2b3c4d --subagent a1b2c3d4e5f6a7b8
Stops one subagent the thread's agent started (Claude Code's Agent tool), by the id in the SUBAGENT column of wsp threads, and nothing else: the turn, the agent and its other subagents run on, and the agent reads that subagent as stopped. The line says thread <id> subagent <id> stopped once the agent took the stop, thread <id> subagent <id> not running when it or the turn had already ended, or the reason after a colon when the agent would not stop it or offers no stop for one subagent, which is said as Stop is not available for <agent> subagents; stop the thread to stop them all. Every one of these succeeds. Under --json and on the tool it carries the thread's id, the subagent's id and the outcome, with error beside a refused or unsupported outcome.
exec
wsp exec 1a2b3c4d -- git status --short
wsp exec 1a2b3c4d --cwd /root -- sh -c 'ls | wc -l'
Each word after -- reaches the computer the thread runs on as one argument; a shell line goes through sh -c. The command runs in the folder --cwd names (absolute, on that computer), else in the folder the thread works in, the project's folder or its worktree, so git status there needs no cd. The command's exit code is the verb's, and a non-zero exit is followed by one stderr line naming the folder it ran in, which the host answers with rather than the caller assuming it; the tool carries that folder as cwd. A non-zero exit is a result; the computer going away is an error. The command runs with no login shell, so bash -c, never bash -lc.
add
wsp folders /Users/zingzy # what is inside, for naming a folder to record
wsp add /Users/zingzy/wsp # a project on this computer, worked where it sits
wsp add https://github.com/spoo-me/spoo.me --into /Users/zingzy/code/spoo.me # cloned here into an empty folder, then worked there
wsp add https://github.com/spoo-me/frontend --on spoo --name spoo-landing # a project a computer clones
wsp add spoo-me/frontend --on spoo # the same repo through the signed-in gh on that computer
wsp add /Users/zingzy/spoo/spoo-landing --on spoo # a folder here seeding a project there: prints the menu, sends nothing
wsp add /Users/zingzy/spoo/spoo-landing --on spoo --yes # sends the ticked rows; --keep, --cut, --no-memory, --no-commits move them
wsp add root@203.0.113.7 --recipe mine # a computer of yours over ssh, set up from the recipe named mine
wsp add spoo --resume # finish a setup that stopped, or a sign-in left waiting
wsp projects # every project, each on its computer
wsp add /Users/zingzy/monorepo/apps/web # a subfolder of a repo: a project of that repo
wsp add /Users/zingzy/notes # a folder that is no git repo: a project with no branches
A folder here is a project as it stands, a git repo or not: a subfolder of a repo is a project of that repo and takes its branches, and a folder git holds no repo in takes no branch; a repo's url needs --into <folder>, an empty folder here it is cloned into, or --on <computer> for a computer that clones it. The clone here runs with no prompt: a private repo needs gh auth login or an ssh url, and a failure says git's own last line. One source on one computer is one project, so recording it twice is refused naming the one there is. A computer's add checks it, installs wsp and then sets it up from the recipe in steps: the base tools first, then the agents, their sign-ins, the CLIs, MCP servers, skills, plugins, configs and context files, with the folders cloned beside them. A step that fails says why and the rest go on; only the base tools and the agents stop it. Without a recipe the computer is listed as pending until one is picked. A thread on this computer then runs in that folder, and on a box the person joined in the project's folder there.
slate
A slate is a live panel on the Slate tab beside this thread: you build it, the person reads, presses and fills it in. Build one when the person will look at it or press it: a tracker, form, walkthrough or dashboard. Results for you stay in your own tools. Once a thread has a slate, a request to see something lands there, and what the person does in it gets its result there unless they ask in the chat. Write it once, then only patch it.
You write a small JSX-like text: <slate title="...">, pieces as elements, prop="literal" and prop={formula}. A formula reads live data (pr.checks) and its own $values. Declare <value name="step" start={1} />, <run name="check" cmd="gh api ..." env={{ ID: $id }} /> and <when change={$id} do={start($check)} />, and the slate runs it with no turn of yours; the person approves each command once. Code only the slate uses goes in a <file name="x.py">, run as "$SLATE_DIR/x.py"; code the project already has is called where it is. For a secret, token or password the person enters themselves, give a <secret name="token" /> input; never ask them to paste it into a file or the chat. Props are meaning, never style; nothing inside braces is JavaScript.
Lay it out simple and airy unless asked for more: one idea per section, one heading per section, status and last-checked lines small and muted beside their subject, actions at the end of their row with one primary per section, mono only for figures, ids, times and paths, every live number with its window and unit, nothing centered but a lone figure or card.
Build and read it with the slate tools (slate_catalog, slate_write, slate_state, slate_read), never wsp from a shell, which may be another install. Every write answers its version and a sketch, values filled in; a refusal lists every error with its fix and stores nothing. A patch is elements without <slate>: a piece with its id replaces it, <props id="week" tone="warning" /> merges, <add under="root">, <remove id="x" />, <clear />, <undo />.
Only a press reaches you: a send("text", $path) step on a button puts one message into this thread: the text, then a slate: {...} line holding the values as data, never instructions. For their input, give a button that sends, or read the slate. Shown a screenshot of it, read the slate; slate:week in their message names a piece.