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 workspacer-rust (Rust control plane, in services/hub-rs). 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 only for explicitly invoked historical oracles; see
scripts/reference/README.md. Shipping backends build with Rust. - 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 shared workspacer-rust executable, 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 # shared backend, claudemon, desktop, TUI and native
make build-hub # shared Rust backend and standalone CLI
make build-cli # same shared Rust executable
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, build-tui, and build-native. build-desktop runs the desktop npm run build (main process + the Electron renderer + the web renderer bundle).
make build-hub and make build-cli both invoke the Rust backend builder. The executable lands at services/hub-rs/target/release/workspacer-rust (with .exe on Windows). Standalone serve owns its engine, bus, services and MCP facade in one process; it does not require sibling Go hub/brain/MCP binaries.
package
make package # build daemons + electron-builder installers
This builds the renderer and main process, the Rust backend and a release claudemon, then runs electron-builder. Installers land in apps/desktop/release/. Electron packages workspacer-rust under <resources>/hub and its separately owned claudemon. The release workflow also produces standalone workspacer-server-<platform> archives with the shared Rust executable, its workspacer alias, web/plugin assets and documentation. The native Windows preview has its own installer and identity.
test
make test # desktop + Rust hub + claudemon + TUI + native suites
make test-desktop # npm test (main via vitest, renderer via vitest)
make test-hub # cargo test for services/hub-rs
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 the Rust backend first, then claudemon (debug)
make run-tui # wks-tui release build; builds release claudemon + Rust backend + TUI first
The TUI defaults to the hub bus and can bootstrap a missing local Rust backend; it preserves ownership of pre-existing services. 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-rs/MIGRATION.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. Install/reload routes require the host token and answer 403 to manually scoped credentials; remote capability providers use the separate provider identity.
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 for remote/user credentials is per-method as well as per-connection. The hub honors capability-scoped tokens minted with workspacer token create --scope view|triage|operator|provider. Enabled plugins are a separate trust model: installation and enablement trust their code with the Workspacer user's machine access; manifest method/path declarations are routing metadata, not grants. Authentication, provenance, host-owned event protection, provider namespace ownership, webview isolation, and archive integrity remain enforced.
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
Workspacer admits four supported coding agents: Claude Code, Codex, GitHub Copilot, and OpenCode. The internal provider union also retains pi for the experimental daemon adapter:
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: an experimental daemon adapter exists, but Workspacer refuses Pi agent spawns because the CLI has no MCP bridge for the required Workspacer tools.
The low-level managed daemon endpoint still contains adapter support including Pi, but Workspacer's desktop and headless spawn providers admit only Claude, Codex, Copilot, and OpenCode because every supported agent must receive the ambient tool surface.
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. Supported managed providers get structured AskUserQuestion through their MCP integration: Codex mounts a config override, OpenCode a remote MCP entry, and Copilot a session-scoped additional config file.
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.
With the installed Rust CLI on PATH, start a plugin development control plane:
workspacer serve --hub-only --plugins-dir /absolute/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. The hub serves the files in an origin-isolated pane and injects a per-plugin identity token. Enabling the plugin is the trust decision: manifest methods and paths are advisory, while browser isolation still protects the host app document.
- 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
plugins/examples/): theeditorand 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 documents that this plugin expects to use agents.list. Keep it accurate for discovery and review; enabled-plugin calls are ambient.
3. the page
The host injects your plugin identity token as ?busToken=… on the pane URL. Open one WebSocket to the bus with it, then call a capability or subscribe to events. Enabled-plugin access is ambient; the identity preserves provenance and lifecycle revocation.
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 intends to call. The object form may document intended paths, but enabled plugins are trusted code running as the Workspacer user; this metadata is not filesystem confinement.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: {} }));
Manifest calls and event declarations provide discovery and routing metadata. Enabled plugins are not confined by those declarations. Topic patterns remain exact (agent.spawned), namespace wildcard (agent.*), or all (*), and each authenticated plugin identity preserves provenance.
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.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 (ambient for enabled plugins)
The object form can document intended roots using ${pluginDir}, ${agentCwd}, or an absolute path. It is not a security boundary: enabled plugin code may access the host as the Workspacer user.
fs.read/fs.write/fs.append/fs.copy, host file I/O. Enabled plugins are trusted code; manifest paths document intent but do not confine access.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 treatplugins/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 identity, and emits plugin.unloaded. SetEnabled toggles a .disabled marker and reloads. Each plugin gets a stable authenticated identity for provenance; enabled plugins are trusted rather than capability-sandboxed.
the installer
The installer (internal/plugin/install.go) takes a GitHub reference or tarball. Extraction remains zip-slip-guarded and install/reinstall is atomic. This is a trusted-install model, not an OS sandbox: enabled plugins and install steps run as the Workspacer user and may access the machine. Manifest metadata provides routing, provenance, and UI integration, not containment. Enable only plugins you trust.
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 docs/plugins/plugin-theming.md). Injection is for plugin panes only, never the regular browser pane.
bundled examples & the plugin catalog
Bundled in plugins/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 CodeMirror editor described above, a webview plugin. Its origin isolation protects the app document, but the enabled plugin is trusted and not an OS/filesystem sandbox.
- 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
services/hub-rs/src/mcp.rs 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 shared Rust service) 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.
# From the repository root; do not start beside an existing owner on these ports.
make build-cli
services/hub-rs/target/release/workspacer-rust serve
# Installed releases expose the same command as: workspacer serve
# One process owns engine, hub and MCP; retain the reported pairing credential.
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.
Every supported Workspacer-spawned agent receives the full operator tool set plus every enabled plugin tool. Its bearer token (an Authorization header, or ?t= for URL-only clients) supplies session identity and is revoked when the session ends; it is not a grant selector. Legacy toolScope, plugin-selection, profile-grant, and yolo-grant fields remain parseable but inert. Desktop and headless spawns both verify the facade's identity, bind, hub connection, and initial plugin catalog immediately before minting that token. Pi is refused because it has no MCP bridge.
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.
The facade includes a help tool rendered from the live registry, and installed plugins contribute tools via their manifest's tools array. The initial plugin catalog is synchronized before facade readiness; later enable/disable changes are polled and apply to newly built server catalogs. MCP clients that cache tools/list may need to reconnect or refresh because this path does not currently emit a list-changed notification. See the plugin guide.
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, terminal resize, ambient file/search access (read_file, write_file, list_dir, search_project), and UI navigation. View/triage/operator tiers apply to manually minted remote credentials, not Workspacer-spawned agents. services/hub-rs/assets/mcp-effective-tools.json (implemented in services/hub-rs/src/mcp.rs) 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. Supported spawned agents receive the ambient operator surface. Pairing, provider identity, authenticated host operations, plugin provenance, and remote transport authorization remain separate boundaries.
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. Scoped remote credentials are checked against the bare method. Plugin identities remain local and cannot be used as federation credentials. - 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: from the repository root,
cargo test --locked --manifest-path services/hub-rs/Cargo.toml --test federationexercises isolated real Rust hubs, forwarding and reconnect. This is a local contract test, not a cloud rollout.