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 the mcp + brain binaries).
  • Node 22 for the Electron app and the Vite renderer.
  • Rust via the standard cargo / rustup toolchain (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 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=1 forces it on at launch. Default bind is 0.0.0.0:7895.
  • WORKSPACER_REMOTE_ADDR=host:port overrides 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 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 three Go binaries in services/hub/: hub (./cmd/hub), mcp (./cmd/mcp), and brain (./cmd/brain).

package

make package             # build daemons + electron-builder installers

This builds the renderer and main process, builds hub, mcp, and a release claudemon, then runs electron-builder. Installers land in apps/desktop/release/.

test

make test                # desktop + hub + tui suites
make test-desktop        # npm test (main via vitest, renderer via vitest)
make test-hub            # go test -race ./...
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 on 127.0.0.1:7890, holds per-session state and PTYs, and serves session state + bidirectional control on 127.0.0.1:7891 over REST + SSE. It parses the ~/.claude/projects/*/*.jsonl transcripts so any client gets a structured conversation, not raw scrollback.
  • 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 is 127.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.ts spawns claudemon serve --hook-port 7890 --api-port 7891.
  • hubDaemon.ts spawns hub with --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, the app kills any stale process already listening on the daemon's port (killStaleListener, via lsof -ti :<port> on Unix, netstat/taskkill on Windows), then polls /health for up to 5s before declaring the daemon ready.

the hub bus

Each client opens one bidirectional WebSocket to ws://<addr>/bus and exchanges JSON frames. Publish and subscribe share the same pipe (deliberately, vs 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, /plugins/install, and /plugins/remove. Empty means no auth, which is only safe because the default bind is loopback.

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.

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) returns agent-<sessionId>, so every client produces the same card id for a session and never diverges.
  • dedupe on sync: dedupeBySessionId runs whenever a layout is taken in, collapsing any cards that still share a sessionId down to one. The survivor is the lexicographically-smallest id (so every client agrees) placed at the first occurrence (so ordering stays stable). Cards with no sessionId (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 at 30000ms.
  • gives up after 10 consecutive 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 four coding agents: Claude Code, Codex, OpenCode, and Pi (beta). The provider type lives in apps/desktop/src/renderer/src/types/pane.ts:

type AgentProvider = 'claude' | 'codex' | '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 no PTY in this mode.

In the desktop main process (apps/desktop/src/main/ipc.ts), the CLAUDE_SPAWN handler routes any provider other than claude to claudemonSessionClient.spawnManaged({ provider, cwd, model, bin }) instead of spawning a PTY.

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 behind claude.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 and AskUserQuestions arrive as can_use_tool control requests, and model / permission-mode switches go out as set_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, default 127.0.0.1:4096, OpenAPI 3.1), creates/drives a session over HTTP, and consumes the GET /event SSE 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 spawns codex app-server (JSON-RPC 2.0 over stdio, newline-delimited). It does thread/start (model + cwd), turn/start per 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).
  • 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, 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.

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.

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.
  • Examples: hello-scoped (the minimal one), agent-dashboard, the sandboxed editor.

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 + health path so the supervisor can health-check it and surface sidecar.* status colors.
  • Examples: rules-engine (event→action interpreter + rule-editor pane), rivet-bridge (re-exposes an external MCP server as hub capabilities).

The two aren't mutually exclusive in spirit — a sidecar can also serve panes — 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. (This is the shipped hello-scoped example, trimmed.)

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 — same frames, 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, id is required and is the install dir / token key.
  • server, the sidecar process: command (required when server is set), args, port, health (a path, e.g. /health). The hub serves the plugin's panes from http://127.0.0.1:<port><path>.
  • ui, instead of server, 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 (not plugin.json or .bus-token).
  • panes, pane types injected into the UI. Each: type (unique id), title, icon, path, and scope (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), and command, which is either open-pane:<paneType> or emit:<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 declare paths or 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, like rivet-bridge).
  • 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 as window.__WKS_SETTINGS__ + a wks-settings event.
  • install, a one-time setup argv run in the plugin dir after a GitHub install (e.g. ["go","build","-o","rules-engine","."]).

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) — 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 — which pane/tab the user is looking at (subscribe to ui.* to follow focus).
  • fs.changed, a file you're watching changed (paired with the fs.watch capability).
  • 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.tick or rules.fired. Declare each type (or a myplugin.* wildcard) in emits. Other plugins can consume them.
  • command.focus_agent, ask the app to focus an agent, data: { sessionId }. The renderer acts on it (the agent-dashboard uses this to jump the desktop to a 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) — 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 — 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 the fs.changed event).
  • search.project (ripgrep-backed, scoped to a cwd), and the broader search.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 — that's how rivet-bridge exposes recon.* / witness.* / schema.*. 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 — treat services/hub/examples/ and apps/desktop/src/main/services/hubCapabilities.ts as 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.

shipped examples

In services/hub/examples/:

  • rules-engine: an always-on event→action interpreter. A Go sidecar on the bus plus a webview rule editor. Rules match events and fire actions: notify, sendMessage, command, emit, webhook. Persists rules to JSON; emits rules.fired. Example: on agent.state_changed → notify.
  • rivet-bridge: the inverse of the MCP facade: it consumes the external rivet MCP server and re-exposes its tools as hub capabilities (recon.*, witness.*, schema.*) so the fleet (rules-engine, dashboards, supervisors) can call them. Needs the external rivet binary; install builds it with go build.
  • agent-dashboard: a bus-native agent grid webview. Consumes agent.state_changed / agent.snapshot; uses agents.list / agents.sendMessage / notifications.post.
  • clock-plugin: the minimal demo: one webview pane and one hotkey (ctrl+shift+kopen-pane:example.clock). Start here if you're writing your first plugin.

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:

{
  "mcpServers": {
    "workspacer": { "type": "http", "url": "http://127.0.0.1:7897/mcp" }
  }
}

The facade registers around 38 tools, each 1:1 with a hub capability. 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, and path-scoped file/search access (read_file, write_file, list_dir, search_project). 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. Authorization for the facade is a stubbed allow-all seam today (per-method capability tokens are the next milestone, not shipped).

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.

work{spacer} :: build & internals :: operational docs →