building from source
This page is for people building work{spacer} from the repo or extending it. If you just want to use it, the operational docs are what you want.
work{spacer} is a monorepo. The Electron desktop app in apps/desktop is the primary client. It spawns and supervises two daemons at runtime: claudemon (Rust, in services/claudemon) and hub (Go, in services/hub). A Rust TUI lives in apps/tui. The root Makefile is the single entry point; every target just delegates into the right component directory.
toolchains
Toolchains are pinned with mise via mise.toml at the repo root:
- Go 1.25 for the
hub(and themcp+brain+workspacerbinaries). - Node 22 for the Electron app and the Vite renderer.
- Rust via the standard
cargo/rustuptoolchain (not pinned by mise).
mise.toml also puts ./apps/desktop/node_modules/.bin on PATH so the app's local binaries resolve inside tasks.
install
From the repo root:
make install # cd apps/desktop && npm install
That installs the desktop JS deps. A postinstall step runs electron-rebuild against better-sqlite3 so the native module matches your Electron version.
dev
make dev # desktop app in dev mode
make dev-share # same dev loop, remote sharing forced ON
./dev # thin wrapper around npm run dev
make dev runs npm run dev. The dev loop builds the hub, brain, and mcp binaries, builds a release claudemon, builds the main process, ensures the web renderer bundle exists, starts the Vite renderer on http://localhost:5173, waits for it, then launches Electron with ELECTRON_DEV=1 and hot reload.
make dev-share runs npm run dev:share, which sets WORKSPACER_REMOTE_SHARE=1 first and is otherwise the same loop. ./dev is a thin wrapper that runs npm run dev straight.
remote sharing. Sharing is a runtime toggle in the app (Remote control → Start sharing). When it is on, or forced on with WORKSPACER_REMOTE_SHARE=1, the hub binds beyond loopback so another PC or phone (intended over a Tailscale tailnet) can reach the bus and the web clients (/m, /remote, /app). The URL and token show up in the app's Hub status.
WORKSPACER_REMOTE_SHARE=1forces it on at launch. Default bind is0.0.0.0:7895.WORKSPACER_REMOTE_ADDR=host:portoverrides the bind address. Pin it to your tailnet IP to keep it off the LAN.- Default (unset): the hub stays on
127.0.0.1. A bus token is always created either way; remote mode reuses it as the bearer secret.
building claudemon by itself
The dev loop already builds claudemon, so there's nothing extra to do before spawning agents. To rebuild just the daemon (e.g. while iterating on Rust code):
make build-claudemon # cargo build --release in services/claudemon
The binary lands at services/claudemon/target/release/claudemon. make build also covers it.
build
make build # build all four components
make build-hub # go build the hub + mcp + brain + workspacer binaries
make build-cli # the workspacer CLI + all its sibling daemon binaries
make build-claudemon # cargo build --release for claudemon
make build-tui # cargo build --release for wks-tui
make build runs build-hub, build-claudemon, build-desktop, and build-tui. build-desktop runs the desktop npm run build (main process + the Electron renderer + the web renderer bundle).
make build-hub produces four Go binaries in services/hub/: hub (./cmd/hub), mcp (./cmd/mcp), brain (./cmd/brain), and the workspacer CLI (./cmd/workspacer, the headless-server launcher; make build-cli builds it together with claudemon, the same sibling-binary layout the standalone server bundle ships).
package
make package # build daemons + electron-builder installers
This builds the renderer and main process, builds the Go binaries and a release claudemon, then runs electron-builder. Installers land in apps/desktop/release/. The packaged app bundles the workspacer CLI (and brain) as siblings of the daemons in <resources>/hub; the release workflow additionally produces the standalone workspacer-server-<os>-<arch> archives (the four binaries + the web build + a README).
test
make test # desktop + hub + claudemon + tui suites
make test-desktop # npm test (main via vitest, renderer via vitest)
make test-hub # go test -race ./...
make test-claudemon # cargo test in services/claudemon
make test-tui # cargo test
The desktop app also has an e2e suite (npm run test:e2e, Playwright) that is not part of make test.
run the tui
make dev-tui # wks-tui debug build; builds hub/brain first, then claudemon (debug)
make run-tui # wks-tui release build; builds release claudemon + hub + tui first
The TUI defaults to the hub bus and auto-spawns the hub and brain. Pass ARGS="--direct" for the standalone claudemon-direct path, e.g. make run-tui ARGS="--direct".
GPU escape hatch
Some Linux GPUs (hybrid Intel + AMD/NVIDIA laptops under Wayland) render the Chromium surface as garbage, speckled corruption. If you hit that:
WORKSPACER_DISABLE_GPU=1 ./dev
When set to 1, the app calls disableHardwareAcceleration() and adds --disable-gpu-compositing. Single-GPU machines that render fine should leave it unset.
per-component builds
Every component also builds on its own from its directory; the make targets just delegate. See the per-component READMEs: apps/desktop/README.md, apps/tui/README.md, services/claudemon/README.md, services/hub/README.md.
make clean # remove build artifacts across all components
architecture internals
work{spacer} is three processes, not one. The desktop app you click is just a client. The actual work (the agent sessions, the PTYs, the transcripts) lives in daemons that keep running with or without a window open.
the three processes
- desktop (
apps/desktop), the Electron + React app. This is the primary GUI client. Electron main spawns and supervises the two daemons below as child processes, connects to the hub as a bus client, and forwards events into the renderer. It owns nothing about sessions itself, it just drives them. - claudemon (
services/claudemon, Rust), owns the sessions. It ingests Claude Code hook events on127.0.0.1:7890, holds per-session state and PTYs, and serves session state + bidirectional control on127.0.0.1:7891over REST + SSE. It parses the~/.claude/projects/*/*.jsonltranscripts so any client gets a structured conversation, not raw scrollback. That REST + SSE surface is documented in full on build a client, which is the seam to write your own UI against. - hub (
services/hub, Go), the control-plane. A WebSocket event bus, a process supervisor, a capability (RPC) router, the plugin system, and an MCP facade. It runs independently of the UI so plugins and remote clients can broker events with or without a window open. Default bind is127.0.0.1:7895.
A Rust TUI (apps/tui, wks-tui) is a second client. Remote and web clients connect to the same hub bus. A session running on one client can be observed and driven from another.
who spawns what
Electron main is the supervisor of the daemons:
claudemonDaemon.tsspawnsclaudemon serve --hook-port 7890 --api-port 7891.hubDaemon.tsspawnshubwith--addr,--claudemon-events http://127.0.0.1:7891/events(so claudemon becomes the first producer on the bus),--plugins-dir,--claudemon, and a--token.
Binary resolution is mode-aware. In dev (ELECTRON_DEV=1 or not packaged) the binaries come from services/claudemon/target/release/claudemon and services/hub/hub. Packaged, they come from <resourcesPath>/claudemon/ and <resourcesPath>/hub/. Both daemons must already be built, make build-claudemon and make build-hub, or make build for everything.
Before spawning, each supervisor probes the port's /health: a healthy daemon already there (a running workspacer serve) is adopted: no spawn, no supervision, never signaled on quit. Only an occupied-but-dead port gets the stale-process kill (killStaleListener, via lsof -ti :<port> on Unix, netstat/taskkill on Windows) before spawning and polling /health for up to 5s.
The same stack also comes up with no Electron at all: workspacer serve (./cmd/workspacer) resolves its sibling binaries, starts claudemon plus the hub with --brain-scope full, wires the shared token through, and supervises both with the desktop's restart-backoff semantics. That's what the standalone server bundle runs.
the hub bus
Each client opens one bidirectional WebSocket to ws://<addr>/bus and exchanges JSON frames. Publish and subscribe share the same pipe, unlike claudemon's SSE-down / POST-up split, because it keeps plugins simple.
Pub/sub. Subscribe to topics, publish events:
client → hub: {"op":"subscribe","topics":["agent.*"]}
{"op":"publish","event":{"type":"agent.spawned","source":"plugin.x","data":{...}}}
hub → client: {"op":"event","event":{...}}
Topic patterns are exact (agent.spawned), namespace wildcard (agent.*), or all (*). The event envelope is { id, type, source, time, data }; the hub stamps id/time if you leave them blank. Fan-out is non-blocking: a slow client gets events dropped and counted rather than stalling everyone else.
Capabilities (RPC). The same socket carries request/reply. A provider registers method names; a caller calls them; the hub routes the call to the owning provider and the reply back, correlating by a global id:
provider → hub: {"op":"register","methods":["agents.list","agents.sendMessage"]}
caller → hub: {"op":"call","id":"req-1","method":"agents.list","params":{...}}
hub → provider: {"op":"call","id":"<global>","method":"agents.list","params":{...}}
provider → hub: {"op":"result","id":"<global>","result":{...}}
hub → caller: {"op":"result","id":"req-1","result":{...}}
The hub never executes a capability, it only routes. The router is single-owner per method: two providers registering the same method would collide. That constraint shapes the whole "who owns what" story. The MCP facade (cmd/mcp) is just another caller on this bus, re-exposing each capability as an MCP tool.
In the running app, Electron main is the capability provider (hubCapabilities.ts): it provides agents.list, agents.sendMessage, notifications.post, and the rest. Because those caps only exist while the GUI is up, there's a headless provider, cmd/brain, that can register the same surface against claudemon's HTTP API with no window open. Single-owner routing is why brain runs in scopes: full (headless, provides everything) or catalog (alongside the app, owns only the file-backed subset while the app keeps the live agent caps). The desktop spawns the hub with --brain-scope catalog or off depending on brainDelegation.ts.
token auth
The hub always loads or creates a bus token, even on the localhost-only default. It lives at <configDir>/remote-token (created with mode 0600, 24 random bytes, base64url). The local hub client presents it; that's how the bus tells the trusted host apart from plugin sidecars and webviews, which carry their own per-plugin tokens. This is the basis of plugin capability enforcement.
The hub flag is --token (or $HUB_TOKEN). When set, auth is required on /bus, /remote, and the /plugins/* admin routes. Empty means no auth, which is only safe because the default bind is loopback. Three of those routes ask for more than the token: /plugins/install, /plugins/examples/install and /plugins/reload run code on the hub's own machine, so they require the host token and answer 403 to a scoped token of any tier — including operator, which is what a remote worker node carries.
Remote sharing reuses the same token as a bearer secret. It's opt-in via WORKSPACER_REMOTE_SHARE; setting it makes the hub bind off loopback (default 0.0.0.0:7895, overridable with WORKSPACER_REMOTE_ADDR, intended to be pinned to a Tailscale IP). Binding off localhost without auth is meaningless, so in that mode the token is required on /bus.
Authorization is per-method as well as per-connection. Besides the host token (implicit full access) and per-plugin tokens (manifest-scoped), the hub honors capability-scoped tokens minted with workspacer token create --scope view|triage|operator|provider. Grant sets are enforced at the router's single dispatch path and fail closed on unknown methods; view and triage tokens can subscribe to events but never publish or register providers, while operator is the single wildcard, treated exactly like the host token on the bus (it may publish, register providers, and pass the token-guarded HTTP routes) — with one exception, the plugin-install family above, which requires the host token itself. provider is the mirror image and deliberately not a rung on that ladder: it is what a headless capability provider holds (a remote node running brain --hub) — it may register capabilities and publish the topics carrying the output of what it registered, it calls exactly one method (layout.get), it subscribes to nothing, and it is refused nodes.wake/nodes.sleep, jobs.* and the token-guarded HTTP routes. Before it, the only credential that could register a capability without a plugin manifest was operator, so a node held eight authorities it never used. Tokens persist in <configDir>/tokens.json (0600, next to remote-token) and the hub re-reads the file on change, so mint/revoke needs no restart. See internal/authtoken and the hub README for the exact grant tables.
one agent, one card, everywhere
The layout is mirrored across clients (desktop, web, phone) through a hub-owned shared document, last-writer-wins. The real identity of an agent is its live claudemon sessionId. Historically each client minted a random card id, so the same session got a different id on every client and a blind whole-array sync made them ping-pong (spawn one, end up with seven).
Two defenses live in agentIdentity.ts:
- deterministic id:
agentIdForSession(sessionId)returnsagent-<sessionId>, so every client produces the same card id for a session and never diverges. - dedupe on sync:
dedupeBySessionIdruns whenever a layout is taken in, collapsing any cards that still share asessionIddown to one. The survivor is the lexicographically-smallest id (so every client agrees) placed at the first occurrence (so ordering stays stable). Cards with nosessionId(stopped or local agents) are always kept.
The net effect: one card per session across every client.
auto-restart with backoff
Both daemons are supervised the same way (RestartBackoff in daemonUtils.ts). On an unexpected exit (not an intentional stopClaudemon()/stopHub()), the app respawns with exponential backoff:
- base delay
1000ms, doubling each attempt, capped at30000ms. - gives up after
10consecutive failures (then logs "restart the app to recover"). - if the daemon stayed healthy for at least
60000ms, the next crash is treated as a fresh failure and the counter resets, so transient crashes don't burn the budget.
An intentional stop sets a flag so the supervised process isn't respawned, and clears the failure counter so the next manual start begins fresh. The hub in turn supervises its own children the same way: spawn, health-check, restart-on-crash, graceful stop (SIGTERM then SIGKILL), with each lifecycle change reported on the bus as a sidecar.* event. That covers plugin sidecars and the brain provider.
how agents are driven
work{spacer} drives five coding agents: Claude Code, Codex, GitHub Copilot, OpenCode, and Pi (beta). The provider type lives in apps/desktop/src/renderer/src/types/pane.ts:
type AgentProvider = 'claude' | 'codex' | 'copilot' | 'opencode' | 'pi'
undefined is treated as 'claude' for back-compat, so any agent or config that predates multi-provider support keeps working.
the two tiers
There are two integration tiers, decided by provider.
Tier-1 (PTY / terminal view). A provider runs as its own interactive CLI inside a PTY. The launch side is provider-agnostic: apps/desktop/src/main/services/agentProviders.ts resolves the binary on PATH (resolveAgentBinary looks for codex / opencode, falling back to the bare command name so a freshly-installed CLI works without a restart) and buildAgentArgv just returns [binary] for non-Claude providers. Claude itself always gets the full flag set from claudeResolver (--session-id / --model / --resume / profile env like CLAUDE_CONFIG_DIR).
Tier-2 (managed adapter). claudemon drives the provider's own machine interface and translates its events into claudemon's session model, so the agent lights up the GUI and Fleet Deck with the same telemetry (mode/state, live conversation, token/cost/usage, pending approvals) as a Claude session. There is normally no PTY in this mode. Codex's default hybrid transport is the exception: the native Codex TUI runs in a PTY (the Term view) alongside the managed thread, one shared conversation.
In the desktop main process (apps/desktop/src/main/ipc.ts), the CLAUDE_SPAWN handler routes every managed spawn through the shared spawnManagedAgent (managedSpawn.ts) instead of spawning a PTY. That covers non-Claude providers, Claude on the stream transport, and headless (transport:"stream") Codex. The hub-bus agents.spawn capability uses the same helper so the two spawn transports can't drift.
How each managed provider is driven (claudemon, services/claudemon/src/providers/):
- Claude (terminal transport): observed indirectly via injected hooks (lifecycle events POSTed to claudemon), the JSONL transcript tail (
~/.claude), and the statusLine (context % / cost / rate limits). This is the original behavior, kept behindclaude.transport: pty. - Claude, headless transport (the default): claudemon spawns
claude --print --input-format stream-json --output-format stream-json(claude_stream.rs) and drives the control protocol directly: approvals andAskUserQuestions arrive ascan_use_toolcontrol requests, and model / permission-mode switches go out asset_model/set_permission_mode. No PTY, so the pane is GUI-only. Hooks still run for headless sessions, so the hook-derived state machine agrees across both transports. - OpenCode: claudemon spawns
opencode serve(headless HTTP, default127.0.0.1:4096, OpenAPI 3.1), creates/drives a session over HTTP, and consumes theGET /eventSSE stream. Frames look like{ "type": "<entity>.<action>", "properties": {…} }(e.g.session.idle,message.part.updated,permission.updated). The translator is defensive: unknown event types and missing fields are ignored, not errors. - Codex: claudemon runs
codex app-serveras a WebSocket daemon (codex app-server --listen ws://127.0.0.1:<port>, JSON-RPC 2.0 over ws) rather than over stdio. A plain ws endpoint is the one transport the native TUI (codex --remote ws://…) and claudemon's RPC client can share, which is what makes the default hybrid (GUI + Term) session possible. Ownership is TUI-first in hybrid: the native TUI creates the thread and claudemon discovers and rejoins it (thread/loaded/list+thread/resume); withtransport:"stream"(the spawn dialog's headless option) the daemon doesthread/startitself, with no native TUI and a GUI-only pane. Either way it doesturn/startper GUI prompt, and consumes notifications:turn/started|completed|failed,item/started|completed,item/agentMessage/delta(streamed text),thread/tokenUsage/updated, and approval requests (item/commandExecution/requestApproval,item/fileChange/requestApproval). A session→thread sidecar under~/.workspacer/codex-threadsmakes it durable: a restarted daemon lazily replays the thread's rollout into the conversation, and a resume rejoins viathread/resume. Codex's wire reports tokens but no dollars, so cost is estimated from the pricing table (session/pricing.rs, user overrides at~/.workspacer/model-rates.json). - GitHub Copilot (beta): claudemon runs ONE
copilot -p <prompt> --output-format jsonprocess per turn and reads newline JSON from its stdout (providers/copilot.rs). The flag that makes a one-shot CLI into a conversation is--session-id <uuid>, which both creates a session with that id and resumes an existing one — so claudemon pins its own session id and every later turn rejoins the same conversation, with no sidecar file. That also makes model / effort switches genuinely live (the next turn is a new argv) and makes Copilot the one managed provider whose restart really does preserve the conversation. Events read:assistant.turn_start,assistant.message_delta,assistant.message,tool.execution_start|complete,model.turn_started/model.model_call_success(real per-call token counts plus Copilot's own context-window size),session.mcp_servers_loaded, and the terminalresult. Two things are unlike every other provider:-pmode cannot ask, so tools always run automatically and the two permission tiers are directory-confined vs.--allow-all; and a hard failure can print prose to stderr while exiting 0, so the adapter proves a turn succeeded (empty stderr, aresultframe, exit code 0, some output) before it ever reports idle. Copilot bills in AI credits, not dollars, so cost is a pricing-table estimate like Codex's. - Pi (beta): claudemon spawns
pi --mode rpc(LF-delimited JSONL over stdio), sends{"type":"prompt", …}per turn, and consumes the streamed lifecycle / text-delta / token-usage / tool events. Pi only gates tools when a permission extension is loaded; those prompts arrive over its Extension UI protocol and surface as normal approvals.
The managed spawn endpoint is POST /sessions/spawn-managed in services/claudemon/src/daemon/spawn.rs. It accepts opencode, codex, copilot, pi, and claude (the headless transport; other values 400). The session id is registered up front (register_managed) and returned immediately; the adapter boots in the background, so the card shows up while the server/process starts.
Internally each adapter is split into a pure per-provider translate() (native event → AgentUpdates, unit-tested) and a shared apply_updates layer. AgentUpdate is the common vocabulary: Idle, Busy, PermissionPending, AssistantText, UserText, ToolUse, Usage, Error.
Approvals. Managed agents forward approval requests to the UI. When the adapter sees a request (requestApproval for Codex, a permission.updated event for OpenCode) it parks the request id and surfaces a pending approval, the same as a Claude one, so the user's decision is sent back to the agent. YOLO mode is the exception: it answers every request accept inline and never parks one.
Questions. Managed providers get structured AskUserQuestion too: claudemon serves a minimal per-session MCP endpoint at POST /mcp/ask/:session_id that parks the session in question mode until /answer. Codex mounts it as an MCP config override, OpenCode as a remote MCP entry (with a long tool timeout so a parked question isn't killed), Copilot as a session-scoped --additional-mcp-config file (the cleanest seam of the five — a flag, so nothing is written into the project), and Pi, which has no MCP client at all, gets it via a generated extension.
Interrupt. /signal SIGINT is structural on managed drivers: Codex turn/interrupt, OpenCode POST /session/:id/abort, Pi's RPC abort. The turn stops without killing the session, so PTY-free clients can cancel too.
writing a plugin
A plugin is a polyglot sidecar (any language) plus a plugin.json manifest that declares what it contributes. The hub reads <plugins-dir>/<name>/plugin.json, validates it, starts the sidecar, and announces the contribution on the bus (plugin.loaded / plugin.unloaded). The desktop renderer picks that up and registers the pane types, command-palette entries, and hotkeys. This section is the overview; for the full hands-on how-to (first plugin, manifest reference, bus protocol, hot-reload, publishing) see build a plugin.
Point the hub at a plugins dir:
go run ./cmd/hub --plugins-dir /path/to/plugins
In the running app the plugins dir is <configDir>/plugins, and the Plugins Manager installs into it for you. For development, drop your plugin's folder in there (or point a dev hub at your own dir) and it loads on the next scan.
the two kinds of plugin
Every plugin is a folder with a plugin.json at its root. What that manifest declares decides which of two shapes it takes. The split is exactly the server vs ui field, and it's the first decision you make.
1. webview-only plugin (ui)
Set ui to a subdirectory of static assets and omit server. There is no process to run: the hub (trusted) serves your files at /plugins/ui/<id>/, and your page opens as a pane in a webview. The webview talks to the bus over a WebSocket using a per-plugin token the host injects into the pane URL (?busToken=…), scoped to exactly the capabilities your manifest declares. Because there's no arbitrary process, there's nothing to escape the bus through, so capability scoping fully confines it.
- Reach for it when your plugin is a UI: a dashboard, a panel, an editor, a rule editor.
- Any language that compiles to static HTML/JS/CSS. No build step needed if you ship plain files.
- Bundled examples (in
services/hub/examples/): the sandboxededitorand thetranscript-timelinereplay pane.
2. sidecar plugin (server)
Set server.command to a long-lived process the hub spawns and supervises (spawn → health-poll → restart-on-crash, SIGTERM then SIGKILL on stop). It's a polyglot sidecar: any language. It connects to the bus itself, so it can run always-on with no pane at all (an automation), provide capabilities other clients call, and/or serve its own webview panes from its port (the hub proxies http://127.0.0.1:<port><path>).
- Reach for it when your plugin does something in the background: reacts to events, calls out to another service, answers capabilities for the rest of the fleet.
- Give it a
port+healthpath so the supervisor can health-check it and surfacesidecar.*status colors. - Examples: the bundled
clock-plugin(the minimal sidecar demo) and the catalog's automations:policy-approver,fleet-guardian,test-on-save,slack-bridge.
A sidecar can serve panes too, so the two are not exclusive in spirit, but a single manifest sets either server or ui as the way its panes are served. If it declares panes with neither, the loader rejects it (the webview would have no URL to load).
write your first plugin
The fastest path is a webview-only plugin that lists the running agents. Three files, no build step.
1. the folder
my-hello/
plugin.json
ui/
index.html
2. the manifest
{
"id": "example.hello",
"name": "Hello",
"apiVersion": "1",
"ui": "ui",
"panes": [
{ "type": "example.hello", "title": "Hello", "icon": "👋", "scope": "both" }
],
"hotkeys": [
{ "id": "open-hello", "default": "ctrl+shift+h", "command": "open-pane:example.hello" }
],
"capabilities": ["agents.list"]
}
ui: "ui" makes it webview-only. panes contributes one pane type; hotkeys binds a key to open it; capabilities asks for the single verb agents.list and nothing else. Ask for only what you use; the bus rejects any call you didn't declare.
3. the page
The host injects your scoped token as ?busToken=… on the pane URL. Open one WebSocket to the bus with it, then call a capability or subscribe to events. The whole client is a few lines:
const token = new URLSearchParams(location.search).get('busToken') || '';
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/bus?token=${encodeURIComponent(token)}`);
let nextId = 1;
const pending = new Map();
function call(method, params = {}) {
return new Promise((resolve, reject) => {
const id = 'c' + nextId++;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ op: 'call', id, method, params }));
});
}
ws.onopen = async () => {
const agents = await call('agents.list'); // one of your declared caps
document.body.textContent = `${agents.length} agent(s) running`;
};
ws.onmessage = (ev) => {
const f = JSON.parse(ev.data);
if (f.op === 'result' && pending.has(f.id)) { pending.get(f.id).resolve(f.result); pending.delete(f.id); }
if (f.op === 'error' && pending.has(f.id)) { pending.get(f.id).reject(new Error(f.error)); pending.delete(f.id); }
};
Style it with the injected --wks-* theme tokens (with fallbacks) so it matches the app; see theming below.
4. load it
Drop my-hello/ into your plugins dir (in the app: <configDir>/plugins; in dev: whatever you passed to --plugins-dir). The hub scans it, validates the manifest, and emits plugin.loaded; the renderer registers your pane and hotkey. Open it with Ctrl+Shift+H or from the command palette. To share it later, push the folder to GitHub and install it by URL from the Plugins Manager.
To make this a sidecar instead, drop ui, add a server block pointing at your process (and an install build step if it needs compiling), and connect to the bus from that process the same way, with the same frames and the same token model.
the manifest (plugin.json)
Schema version is "apiVersion": "1" (the loader rejects anything else). Real fields, from internal/plugin/manifest.go:
id,name,idis required and is the install dir / token key.server, the sidecar process:command(required whenserveris set),args,port,health(a path, e.g./health). The hub serves the plugin's panes fromhttp://127.0.0.1:<port><path>.ui, instead ofserver, a subdir of static assets the hub serves itself at/plugins/ui/<id>/. This is a webview-only plugin with no sidecar process. Only the named subdir is exposed (notplugin.jsonor.bus-token).panes, pane types injected into the UI. Each:type(unique id),title,icon,path, andscope(global= Overview only,agent= inside an agent workspace and gets its sessionId/cwd,both= wherever you are, the default).hotkeys,id,default(e.g.ctrl+shift+a), andcommand, which is eitheropen-pane:<paneType>oremit:<eventType>.capabilities, bus methods the plugin may call. A bare string ("agents.list") for an unscoped verb, or the object form{ "method": "fs.read", "paths": ["${pluginDir}"] }for a filesystem-scoped one. Filesystem methods (fs.*,search.project) must declarepathsor the loader rejects them, so a plugin can never get unrestricted host fs access. Path tokens:${pluginDir},${agentCwd}(bound per open pane), or absolute paths. Anything unresolved grants nothing (fail closed).provides, capabilities the plugin answers on the bus (it becomes a provider other clients can call).emits/consumes, event types it publishes / subscribes to.settings, typed settings (boolean/number/string/select) the host renders in Settings and delivers into the webview aswindow.__WKS_SETTINGS__+ awks-settingsevent.install, a one-time setup argv run in the plugin dir after a GitHub install (e.g.["go","build","-o","server","."]).
talking to the bus
Whether webview or sidecar, a plugin is just another client on the hub bus. It opens one WebSocket to ws://<addr>/bus?token=<busToken> (webviews get the token in the pane URL as ?busToken=; sidecars get theirs in the HUB_TOKEN environment variable) and exchanges JSON frames. Three ops matter:
// subscribe to events (topics you declared in "consumes")
ws.send(JSON.stringify({ op: 'subscribe', topics: ['agent.*', 'ui.*'] }));
// publish an event (a type you declared in "emits")
ws.send(JSON.stringify({ op: 'publish',
event: { type: 'command.focus_agent', source: 'example.hello', data: { sessionId } } }));
// call a capability (a method you declared in "capabilities"); reply comes back as op:'result'
ws.send(JSON.stringify({ op: 'call', id: 'c1', method: 'agents.list', params: {} }));
Two rules the bus enforces against your declared grants: you can only call a method you listed in capabilities, and you can only publish a type you listed in emits. An undeclared call or publish is refused. Topic patterns (in subscribe, consumes, emits) are exact (agent.spawned), namespace wildcard (agent.*), or all (*).
events: what you can consume and emit
Events are fire-and-forget pub/sub (state changes, lifecycle, UI activity). Declare the ones you subscribe to in consumes and the ones you publish in emits. Capabilities (next section) are the request/reply channel. Use those when you need an answer or want to make something happen.
consume: events the fleet publishes to you
agent.spawned, an agent session started.agent.state_changed, an agent's live state changed, the workhorse event: the state dot (idle / working / needs-approval / done), context %, token/cost, and pending approvals/questions. Published by claudemon.agent.snapshot, a full per-agent snapshot (state + usage) for a session, from the catalog/headless provider.agent.statusline, the context % / cost / rate-limit status line for an agent.agent.done, an agent finished working (working → idle). This is the "it needs you or it's finished" signal.agent.terminated, an agent session ended and its card went away.sidecar.running/sidecar.healthy/sidecar.unhealthy/sidecar.crashed/sidecar.stopped, a plugin sidecar's supervisor state (these back the health colors in the Plugins Manager).plugin.loaded/plugin.unloaded, a plugin was loaded or removed.plugin.settings.changed, a plugin's settings were edited in Settings.plugin.install.progress, progress frames during an install.plugin.sandboxed/plugin.unsandboxed/plugin.sandbox.refused, sandbox-state transitions for a plugin.ui.pane.opened/ui.pane.focused/ui.tab.focused, renderer activity naming which pane or tab the user is looking at (subscribe toui.*to follow focus).fs.changed, a file you're watching changed (paired with thefs.watchcapability).git.changed, a repo's git state changed (what the Review pane listens to).
Subscribing to a namespace (agent.*, sidecar.*, plugin.*, ui.*) is the easy way to catch a whole family without listing each type.
emit: events you publish
- Your own namespaced events, anything under your plugin's namespace, e.g.
example.hello.tickorrules.fired. Declare each type (or amyplugin.*wildcard) inemits. Other plugins canconsumethem. command.focus_agent, ask the app to focus an agent,data: { sessionId }. The renderer acts on it (the Fleet Radar and Cost HUD catalog plugins use this to jump the desktop to an agent when you click its card).command.spawn_agent, ask the app to spawn an agent.
The command.* namespace is the "ask the host to do something" channel: you publish, the desktop renderer picks it up and acts. A hotkey can fire one directly with "command": "emit:<eventType>" instead of opening a pane.
capabilities: what you can call and provide
Capabilities are request/reply methods. List the ones you call in capabilities; register the ones you answer in provides. The methods the host registers today (provided by the desktop app, or headlessly by cmd/brain) are the same surface the MCP facade re-exposes as tools:
agents & sessions
agents.list, running agents with state / usage / pending asks.agents.sendMessage, send a prompt to an agent ({ sessionId, text }).agents.spawn/agents.kill, start a new agent (returns its sessionId) / terminate one.agents.poll, pull the latest agent set.claude.approve/claude.answer/claude.signal/claude.gate, resolve an approval, answer an AskUserQuestion, send a signal (SIGINT/SIGTERM/…), gate a deferred hook.claude.listModels/claude.sessionsForDir, model list and resumable sessions for a directory.sessions.list/sessions.snapshot/sessions.snapshots, live session state.sessions.transcript/sessions.conversation, read a session's transcript / structured conversation.sessions.save/sessions.load/sessions.delete, saved-session management.sessions.terminalInput/sessions.terminalResize/sessions.attachTerminal/sessions.detachTerminal/sessions.terminalKeepalive, drive a PTY.
notifications
notifications.post, show a desktop notification ({ title, body }).
filesystem & search (path-scoped)
These are the only methods that must use the object form and declare paths (with ${pluginDir}, ${agentCwd}, or an absolute path). Without a path scope the loader rejects them, so a plugin can never get unrestricted host filesystem access.
fs.read/fs.write/fs.append/fs.copy, file I/O confined to your declared paths.fs.listEntries/fs.listDir/fs.realpath, browse and resolve within scope.fs.watch/fs.unwatch, watch for external changes (delivered back as thefs.changedevent).search.project(ripgrep-backed, scoped to acwd), and the broadersearch.files/search.everything.
provide your own
List method names in provides and answer them on the bus, and you become a first-class capability provider the rest of the fleet (dashboards, rules, supervisors, the MCP facade) can call. A bridge sidecar, for instance, can consume an external MCP server and re-expose its tools as hub capabilities. The router is single-owner per method, so pick a namespace nobody else claims.
The exact params/return shape of each host capability isn't a frozen public API yet, so treatservices/hub/examples/andapps/desktop/src/main/services/hubCapabilities.tsas the source of truth for field names, and the MCP tool list (below) for the stable subset.
the plugin manager
The plugin manager (internal/plugin/manager.go) owns the loaded set. Add starts the sidecar and emits plugin.loaded; Remove stops it, drops its bus token, and emits plugin.unloaded, returning the dir so the caller can delete it without a second lookup (closes the TOCTOU window). SetEnabled toggles a .disabled marker file and reloads, so you can disable a plugin without uninstalling it. Each plugin gets a per-plugin bus token (persisted to .bus-token, stable across restarts) bound to exactly its declared capability grants.
the installer
The installer (internal/plugin/install.go) takes a GitHub reference (owner/repo, a full URL, or a /tree/<ref> / /commit/<sha> URL) or a direct .tar.gz / .tgz URL. For GitHub it tries the given ref, else main then master, via codeload.github.com. Extraction is zip-slip-guarded: every entry's target must stay inside the destination (it strips the GitHub top-level wrapper dir and rejects .. escapes). It extracts into a temp dir on the same filesystem then renames into place (atomic install / reinstall), and runs the manifest's install build step (5-minute timeout). This is the trusted-install model (like a VS Code extension), not a sandbox: it downloads and runs code from the internet, so the caller gets user consent.
the process supervisor
The process supervisor (internal/supervisor/supervisor.go) runs each sidecar: spawn, health-poll, restart-on-crash. If the manifest gives a port + health, the supervisor GETs http://127.0.0.1:<port><health> every 2s (200 == healthy) and emits sidecar.healthy / sidecar.unhealthy as it flips. Graceful stop sends SIGTERM, then SIGKILL after a 5s WaitDelay. An unexpected exit is reported as sidecar.crashed and restarted after a backoff (default 1s). States: stopped, running, healthy, unhealthy, crashed.
theming
Plugin panes render as webviews and the host injects its active theme as --wks-* CSS custom properties on :root (re-injected on every live theme switch and every in-plugin navigation). It also sets zero-specificity :where(html, body) defaults so a plugin with no styling still looks native, and exposes a JS hook for canvas UIs: window.__WKS_THEME__ and a wks-theme event. Reference tokens with a fallback so the page still works in a plain browser: background: var(--wks-bg-base, #1a1a1a). Tokens: --wks-bg-base/-raised/-surface/-elevated, --wks-text-primary/-secondary, --wks-accent, --wks-success/-error/-warning, and more (see services/hub/docs/plugin-theming.md). Injection is for plugin panes only, never the regular browser pane.
bundled examples & the plugin catalog
Bundled in services/hub/examples/:
- clock-plugin: the minimal sidecar demo: one webview pane and one hotkey (
ctrl+shift+k→open-pane:example.clock). Start here if you're writing your first plugin. - editor: the sandboxed CodeMirror editor described above, a webview-only plugin whose filesystem reach is exactly the agent-cwd-scoped
fs.*capabilities its manifest declares. - transcript-timeline: a scrubbable session replay pane. Pick any live or historical session and drag through its prompts, tool calls, and file edits; it can even materialize the replay into a disposable git worktree that physically follows the scrubber.
Beyond the bundled examples there's a public catalog of install-ready plugins at workspacer-plugins: dashboards (Fleet Radar, Cost HUD, Focus Tracker), fleet automations (Policy Approver, Fleet Guardian, Escalation Chains, Typecheck Gate, Test on Save, CI Watcher), and remote reach (Slack Bridge, Phone Push, Standup Digest). Each is a zero-dependency repo you can install straight from the Plugins Manager (paste DJTouchette/workspacer-plugin-<name>), and together they double as reference implementations for every plugin shape above.
the mcp facade & brain
cmd/mcp exposes the hub's capabilities as MCP tools, so an ephemeral claude -p supervisor (or any MCP client) can view and drive the whole fleet. It is a thin adapter: a tool call becomes a bus call, the provider (Electron main, or the headless brain) executes it, and the reply becomes the tool result. The facade never touches workspacer state.
It serves two transports off one server: /mcp (Streamable HTTP, the current MCP HTTP transport) and /sse (legacy SSE), plus /health.
# hub first (the bus), then the facade pointed at it
go run ./cmd/hub --addr 127.0.0.1:7895
go run ./cmd/mcp --addr 127.0.0.1:7897 --hub ws://127.0.0.1:7895/bus
# pass --token / $HUB_TOKEN when the hub requires auth
Attach it to Claude Code via --mcp-config. Mint the client a token first — a credential-less caller is refused (see below):
workspacer token create --scope operator --label "my mcp client"
{
"mcpServers": {
"workspacer": {
"type": "http",
"url": "http://127.0.0.1:7897/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}
Clients whose MCP registration is a bare URL and can't send headers (codex's -c overrides, opencode.json) append the token as ?t=<token> instead.
The facade serves tiered tool sets. A request's bearer token (an Authorization header, or ?t= for URL-only clients) resolves to a tier, one of view (read-only observation), triage (view + approve/reply/interrupt + UI navigation), or operator (everything). tools/list returns only that tier's tools. Tiers are derived from the same authtoken scope allowlists the hub bus enforces, so the two planes can't disagree. The desktop mints a per-session scoped token when you spawn an agent with Workspacer tools (or toolScope on agents.spawn / spawn_agent), and revokes it when the session ends.
A request with no credential at all is refused with 401. The facade speaks plain HTTP on loopback, and loopback is reachable by every process and every user account on the machine (and by a container sharing the host network namespace), so "whoever reached the port" is not an identity and gets no tools. Before 0.160 it got the whole operator set; that is what changed. A DNS-rebinding Host guard and a fail-closed non-loopback bind policy were already in place and are unchanged.
The dial is facade.untokenedAccess in config.yaml (the binary's -untokened flag / WKS_MCP_UNTOKENED), with three positions:
deny— the default. Credential-less requests get401on/mcpand/sse;/healthstays open so liveness probes need no secret.view— credential-less requests get the read-only tier. No spawning or writing, but every agent list and every transcript, to anything on the machine.operator— credential-less requests get everything. The pre-0.160 behaviour, kept as an explicit opt-in for a hand-configured local client that cannot carry a token.
Nothing work{spacer} spawns is affected by any of the three: every facade session carries its own per-session token, presented as an Authorization header on the generated --mcp-config file (Claude PTY and stream) or as ?t= on the URL (codex, opencode, copilot). The setting only governs callers that present nothing.
Every tier includes a help tool rendered from the live tool registry (grouped overview, per-topic usage guidance), and installed plugins can contribute tools of their own via their manifest's tools array, granted per session token and forwarded to the plugin's sidecar over the bus (see the plugin guide). The operator tier registers around 50 core tools, each 1:1 with a hub capability (plus the event-backed UI-navigation set). The core driving set:
list_agents, running agents + state/usage/pending asks.get_transcript, read a session's transcript.spawn_agent, start a new agent; returns its sessionId.create_terminal, open a new shell PTY; returns its sessionId.send_message, send a prompt to an agent.approve, resolve a permission prompt (yes/no/always).answer, answer an AskUserQuestion picker.signal, send a signal (SIGINT/SIGTERM/…).terminal_input, write raw bytes into a PTY.notify, show a desktop notification.
The rest cover snapshots and conversations (get_snapshot, list_snapshots, get_conversation), models and resumable sessions (list_models, list_resumable_sessions), config and profiles (get_config, reload_config, list/add/remove_profiles), saved sessions and layouts, the library, analytics summaries, the approval gate, terminal resize, path-scoped file/search access (read_file, write_file, list_dir, search_project), and UI navigation (focus_agent, open_pane, open_browser, open_plugin, open_spawn_dialog; triage tier and up, published as the renderer's command.* events, so they drive the desktop the same way plugin panes do). cmd/mcp/main.go is the authoritative list.
spawn_agent / create_terminal need the matching capabilities registered by a provider; the session runs headless in claudemon and a desktop pane can attach later. Per-method authorization is enforced twice: the facade filters its own tool tiers per request token, and the hub's dispatch seam confines scoped bus connections (workspacer token create --scope view|triage|operator|provider), failing closed on anything outside the grant set.
Note: the agent-driving capabilities (agents.*, claude.*, sessions.*) are normally provided by the Electron main process, so they only exist while the desktop app is running. cmd/brain is a standalone provider that fills the gap headlessly (backed by claudemon's HTTP API), and the hub can supervise it with --brain-scope full (headless) or --brain-scope catalog (alongside the app). So the MCP facade works with no GUI open.
hub federation (hub-of-hubs)
A hub can link upstream to named peer hubs and republish their fleet onto its own bus, so every client keeps its one-bus invariant while the fleet spans machines. Design + implementation notes: docs/hub-federation.md.
- Links are ordinary bus clients. Each peer in
~/.config/workspacer/peers.json({name, url, token}, mode 0600, since tokens never ride argv or config.yaml) gets an outboundbusclientholding a scoped token the peer minted. The peer needs no concept of federation; the token's tier is the ceiling on the whole link. - Events forward off an allowlist (
agent.*,workflow.*), stamped with the peer name in a new envelope field (hub). Payloads are never rewritten. The allowlist is not*, and each exclusion has a reason: a peer'slayout.changedwould clobber the local layout document, itsplugin.*topics are host-only for a reason, and itscommand.*events would drive this machine's UI. Loop prevention is a tree invariant: an event that already carries a stamp is dropped, never re-forwarded. - Calls qualify:
hub:work/claude.approveforwards to peer "work" with a 25s budget (under the router's 30s, so the federated hop's failure is the one you see). Scoped tokens are tier-checked against the bare method, so the allowlists stay exact-name; plugin tokens are refused federated calls outright, because a consented "this machine" grant must not silently extend to every peer. Local path confinement is skipped, since the paths name the peer's filesystem and the peer enforces its own. - Reachability is first-class:
hub.peer.connected/disconnectedevents drive client tombstones, andfederation.peers(view-tier) reports each link's state so call-seeded clients can merge peer fleets at boot (hub:<peer>/sessions.snapshots). - Try it on one machine:
services/hub/scripts/federation-harness.shruns a fake second PC with synthetic agents, and prints the peers.json line to point a real hub at it.