the seam
The desktop app is not work{spacer}. It's a client. The agent sessions, the PTYs, the transcripts, the token accounting, the approval queue all live in claudemon, a Rust daemon that keeps running whether or not a window is open. Everything the Electron UI shows, it got over HTTP.
Which means anything else can get it too. This page is the contract: run the daemon, speak this API, and you have the whole fleet, every provider and approval and live conversation, with none of our UI.
who this is for
- You want a lighter client: a status bar, a menu-bar widget, a single-pane view, a dashboard on a second monitor.
- You want a different shape entirely: a web app, a mobile app, an Emacs mode, a Raycast extension, a physical button that approves the pending tool call.
- You want to drive agents from a script without a UI at all: spawn, prompt, watch for the answer, move on.
- You're on a machine where an Electron app is the wrong answer, and a browser tab or a terminal is the right one.
There's already a second client proving the seam holds: wks-tui, the Rust terminal client in apps/tui/. It talks to exactly this API and nothing else. When you want to know how a real client handles a case, read that. It is the reference implementation.
claudemon (this page) is the session API, the direct lowest-level surface and the right one for a UI. The hub bus is the control plane on top: pub/sub events, capability RPC, plugins, MCP, remote access. If you're writing a plugin that lives inside work{spacer}, you want the bus instead. If you're writing your own app, you want this.run it headless
Three ways to get an API to talk to. All of them end with claudemon listening on 127.0.0.1:7891.
1. the whole stack, no GUI
The workspacer CLI supervises claudemon plus the hub (and the headless capability provider, brain) as one unit. This is the server bundle. Grab workspacer-server-<os>-<arch> from the releases, or make build-cli from source:
workspacer serve
workspacer serve --json # ready banner as one JSON object on stdout
Useful flags: --host (default 127.0.0.1), --claudemon-api-port (7891), --claudemon-hook-port (7890), --hub-port (7895), --token (or $HUB_TOKEN).
2. claudemon alone
If you only want the session API and none of the control plane, run the daemon straight. This is the smallest thing that works:
claudemon serve
claudemon serve --host 127.0.0.1 --hook-port 7890 --api-port 7891
Two ports, and they do different jobs. --api-port (7891) is this API, the one your client talks to. --hook-port (7890) is where Claude Code's hooks POST their lifecycle events in; you never call it, but the daemon needs it to observe PTY-transport Claude sessions. --db-path overrides the SQLite store.
One-time setup so Claude Code emits those hooks:
claudemon init # merge hooks + statusLine into ~/.claude/settings.json
claudemon init --dry-run # print the merged document instead of writing it
The merge is atomic and idempotent, and leaves your own settings alone. If you'd rather not touch the global file at all, --overlay <path> writes a standalone claudemon-owned settings file to pass to claude --settings instead.
3. alongside the running app
If the desktop app is already open, claudemon is already on 7891, spawned by the app. Point your client at it and you're a second client on the same live sessions. Nothing to start, no conflict: your UI and the desktop UI will see the same fleet and each other's changes, live.
This is the fastest way to develop. Open the app, start building against 7891, watch your client and the real one stay in lockstep.
/health on the port. A healthy daemon that's already there gets adopted: the app uses it and never signals it on quit. So a workspacer serve you started stays yours; opening the GUI later just joins it.check it's up
curl -s http://127.0.0.1:7891/health # -> ok
curl -s http://127.0.0.1:7891/sessions | jq
the session model
One object matters more than all the others. A session is one agent: its state machine, its cwd, its provider, its pending question, its cost. Get this shape and most of the API is obvious.
{
"session_id": "9f3c…", // the identity; use it everywhere
"cwd": "/home/you/project",
"mode": "responding", // the state machine — see below
"pending": null, // what it's waiting on, when paused
"provider": "claude", // claude | codex | copilot | opencode | pi
"transport": "stream", // pty | stream
"started_at": "2026-07-26T10:02:11Z",
"updated_at": "2026-07-26T10:19:40Z",
"tool_calls": 47,
"user_prompts": 6, // 0 = spawned but never actually used
"last_event": "PostToolUse",
"transcript_path": "/home/you/.claude/projects/…/abc.jsonl",
"status_line": { … }, // context % / cost / rate limits
"plan": { "steps": [ … ] }, // the agent's checklist, when it wrote one
"compacting": false,
"last_compact_at": 1753524000,
"compaction_count": 2,
"usage": { … }, // added by the API, not stored on the session
"archived": false // added by the API: stopped + long-idle
}
mode: the state machine
Six values. This is what drives a status dot, and it's the single field most clients render first.
| mode | meaning |
|---|---|
unknown | No signal yet: just spawned, or the daemon restarted and hasn't seen an event. |
input | Idle. The prompt is up, it's waiting for you. Safe to send a message. |
responding | Working. Thinking, calling tools, writing. |
approval | Blocked on you. A tool wants permission. pending tells you which. |
question | Blocked on you. An AskUserQuestion picker is up. pending has the questions. |
stopped | Session ended. Rows stick around as resumable history. |
approval and question override responding on purpose: while a picker is up the agent is waiting on a human, not working. Those two modes are the ones worth a notification, since they're the whole "your fleet needs you" signal.
pending: what it's waiting on
Non-null exactly when mode is approval or question. Tagged by kind:
// kind: "approval"
{ "kind": "approval", "tool": "Bash", "summary": "rm -rf ./dist", "raw": { … } }
// kind: "question"
{ "kind": "question",
"questions": [ { "question": "Which database?",
"header": "Storage",
"multiSelect": false,
"options": [ … ] } ],
"raw": { … } }
Answer the first with /approve, the second with /answer. raw is the untouched upstream payload; reach for it when you need a field the daemon didn't normalize.
provider and transport
provider is which agent backend drives the session: claude, codex, copilot, opencode, or pi. transport is how: pty (there's a real terminal you can attach to and stream bytes from) or stream (headless, with no PTY, so don't offer a terminal view). Gate your UI on transport, not on provider: Claude runs both ways.
http reference
Base URL is http://127.0.0.1:7891. JSON in, JSON out. Every route is in services/claudemon/src/daemon/api.rs, which is the authority if this page ever drifts.
reading the fleet
| route | notes |
|---|---|
GET /health | Returns the string ok. Use it to detect an already-running daemon. |
GET /sessions | Every session as the object above, each with usage and archived merged in. ?include_archived=true adds stopped + long-idle rows (7 days); ?include_empty=true additionally un-hides spawned-but-never-used rows. Both default off. |
GET /sessions/:id | One session, same shape. 404 if unknown. |
GET /sessions/:id/transcript | The raw Claude JSONL transcript. ?cwd= helps locate it when the id and the on-disk name differ. |
GET /sessions/:id/conversation | Structured conversation; see below. ?since=<seq> for incremental catch-up. |
GET /sessions/:id/output | The PTY scrollback buffer (pty transport only). |
GET /usage | Account-level rate-limit windows, no session needed: five_hour_pct, five_hour_resets_at, seven_day_pct, seven_day_resets_at, monthly_pct, monthly_resets_at, out_of_credits, fetched_at. 503 when the account can't be queried, so you can tell unknown from no active window. |
GET /heartbeats | Keep-warm ping log (the scheduled 5h-window warmer). |
driving a session
| route | body |
|---|---|
POST /sessions/:id/message | { "text": "…" }, the one you want. Sends a prompt through the settle-and-verify pipeline: delivered now if the prompt is up, queued and flushed on the next idle transition otherwise (cold start, mid-turn, open dialog). Only a stopped session rejects it. Prefer this over /input for anything user-facing. |
POST /sessions/:id/input | { "text": "…", "newline": true } or { "bytes_b64": "…" }: raw bytes into the PTY, no pipeline, no guarantees. For terminal emulation and keystrokes, not prompts. |
POST /sessions/:id/approve | { "decision": "yes" | "no" | "always", "reason": "…" } resolves a pending approval. reason rides along with a block and shows up in the agent's context. (always is treated as yes; hooks have no "remember this" channel.) |
POST /sessions/:id/answer | { "option": 2 } (1-indexed) or { "text": "…" }. Multi-question prompts take { "answers": ["2", "my answer"] }, one per question in order. Pass answerKinds: ["option","text"] alongside it so a free-text answer that happens to be a number isn't remapped to an option. |
POST /sessions/:id/signal | { "signal": "sigint" | "sigterm" | "sigkill" }. sigint is a structural interrupt on managed providers: it stops the turn without killing the session, so PTY-free clients can cancel too. |
POST /sessions/:id/permission-mode | { "mode": "…" }, a live switch: no restart, conversation untouched. |
POST /sessions/:id/model | Live model switch on providers that support it. |
POST /sessions/:id/resize | Resize the PTY (pty transport). |
POST /sessions/:id/handoff | Build a deterministic handoff brief for another agent to pick up. { "no_persist": true } returns it without writing to disk. |
POST /sessions/:id/decide | { "body": { … } }, the raw hook decision, returned to Claude Code verbatim. Bypasses /approve's opinionated mapping when you need exact control. |
POST /sessions/:id/gate | { "on": true } opts this session into the deferred-hook gateway, parking PreToolUse responses until a client decides. |
starting sessions
| route | body |
|---|---|
POST /sessions/spawn | A PTY session. { "argv": ["claude", …], "cwd": "/path", "cols": 120, "rows": 40, "env": {…}, "session_id": "…" }. argv and cwd are required. |
POST /sessions/spawn-managed | A managed (daemon-driven) session, the modern path. provider must be claude, codex, copilot, opencode, or pi; anything else is a 400. Returns the session id immediately and boots the adapter in the background, so you can render the card while the process starts. |
GET /providers/:provider/models | Models available for codex, copilot, opencode, or pi. ?bin= and ?cwd= override the binary and working dir. |
The managed spawn body in full. Everything but provider and cwd is optional:
{
"provider": "codex", // required: claude | codex | copilot | opencode | pi
"cwd": "/home/you/project", // required
"model": "gpt-5-codex",
"effort": "high",
"bin": "codex", // defaults to the provider name
"yolo": false, // auto-approve everything
"mcp": "…", // MCP config override
"instructions": "…", // system prompt / instructions
"session_id": "…", // pin the id instead of generating one
"transport": "stream", // headless, no native TUI
"permission_mode": "…",
"resume": "…", // resume a prior session id
"extra_args": ["--flag"],
"env": { "KEY": "value" }
}
the live streams
Five SSE endpoints. Every one names its event type, so an EventSource can listen by name. All five send a keep-alive every 15 seconds, so if you go 30+ without a frame you've been disconnected.
The split is deliberate and worth internalizing: SSE down, POST up. There's no bidirectional socket here. You subscribe to what changes, and you act with ordinary HTTP requests. (The hub bus makes the opposite choice, one WebSocket both ways, because plugins are simpler that way.)
| stream | event | payload |
|---|---|---|
GET /events | session.update | { session_id, event, state } where state is the full session object. Start here: this one stream drives a whole fleet view. |
GET /conversation/stream | conversation.delta | { session_id, seq, reset, items }, new conversation items for any session. See below. |
GET /sessions/:id/stream | pty.bytes | base64 PTY output for one session. Opens with a snapshot of the existing scrollback, then live chunks. pty transport only. |
GET /hooks/stream | hook | Raw, unaggregated Claude Code hook events, before the state machine touches them. For building your own session store. |
GET /statusline/stream | statusline | { session_id, cwd, status_line }: context %, cost, rate limits. High-frequency, on its own channel so it can't flood the others. |
Note that /events and /conversation/stream are fleet-wide, not per-session. You open each once for your whole app and route by session_id, rather than opening a stream per agent. A twenty-agent fleet is still two connections.
const events = new EventSource('http://127.0.0.1:7891/events');
events.addEventListener('session.update', (e) => {
const { session_id, state } = JSON.parse(e.data);
if (state.mode === 'approval' || state.mode === 'question') {
notifyMe(session_id, state.pending); // the "it needs you" signal
}
});
the conversation
You don't have to parse terminal scrollback, and you shouldn't. claudemon reads the agent's own transcript and gives you a typed item list, the same one the desktop app renders.
GET /sessions/:id/conversation returns { session_id, seq, items }. Each item is tagged by kind:
| kind | fields |
|---|---|
user_message | text, timestamp |
assistant_text | text, timestamp |
tool_use | id, name, input (raw JSON), timestamp |
tool_result | tool_use_id, content, is_error, timestamp |
usage | model, usage, message_id, sidechain (true for subagent turns) |
plan | steps, updatedAt, the agent's checklist |
slash_command | name, args, timestamp |
command_output | output, is_error, timestamp |
Pair tool_result to tool_use by tool_use_id → id. Ignore kinds you don't know. More will be added, and that's the intended failure mode.
staying in sync
seq is a monotonic counter per session. The pattern:
- Fetch
GET /sessions/:id/conversationonce for the backlog. Keep theseq. - Subscribe to
/conversation/streamand append theitemsof eachconversation.delta. - If a delta arrives with
reset: true, discard your buffer and take the items as the new truth. That's a compaction or a replay rather than an append. - If you dropped frames, re-fetch with
?since=<last_seq>and you'll get only what you missed.
Sessions are capped at 5000 items in memory; older ones fall off the front. The transcript on disk is still complete; use /transcript if you need it all.
a client in 40 lines
A complete fleet monitor: lists agents, shows what each is doing, and approves whatever's blocked. No dependencies, runs in a browser tab or Node.
const API = 'http://127.0.0.1:7891';
const fleet = new Map();
// 1. backlog
const seed = await (await fetch(`${API}/sessions`)).json();
for (const s of seed) fleet.set(s.session_id, s);
render();
// 2. live updates — one stream for every agent
const events = new EventSource(`${API}/events`);
events.addEventListener('session.update', (e) => {
const { session_id, state } = JSON.parse(e.data);
fleet.set(session_id, state);
render();
});
// 3. act
const approve = (id, decision = 'yes') =>
fetch(`${API}/sessions/${id}/approve`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ decision }),
});
const say = (id, text) =>
fetch(`${API}/sessions/${id}/message`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text }),
});
function render() {
const rows = [...fleet.values()].map((s) => {
const dot = { input: '○', responding: '●', approval: '!', question: '?',
stopped: '×', unknown: '·' }[s.mode];
const need = s.pending?.kind === 'approval'
? ` — wants: ${s.pending.tool}`
: s.pending?.kind === 'question'
? ` — asks: ${s.pending.questions[0]?.question}`
: '';
return `${dot} ${s.provider} ${s.cwd}${need}`;
});
console.clear();
console.log(rows.join('\n'));
}
That's the entire loop every work{spacer} client runs, ours included: seed from /sessions, subscribe to /events, POST to act. Add /conversation/stream and you have a chat UI. Add /sessions/:id/stream and you have a terminal.
spawning one
const res = await fetch(`${API}/sessions/spawn-managed`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ provider: 'claude', cwd: '/home/you/project',
transport: 'stream' }),
});
const { session_id } = await res.json();
await say(session_id, 'Read the README and summarize the architecture.');
The id comes back before the adapter finishes booting, so render the card immediately and let the first session.update fill it in.
the local security model
claudemon can spawn processes and write files. It is bound to loopback and defended as a local-only service, which is worth understanding before you expose it.
- Loopback by default.
--hostdefaults to127.0.0.1. There is no authentication on the claudemon API. Its security boundary is the bind address. - Host-header guard. Requests whose
Hostisn't loopback (or the daemon's own bind address) get403, before any handler runs. This kills DNS rebinding, where a malicious page resolves its own domain to 127.0.0.1 to drive your daemon. - Loopback-scoped CORS. Only loopback origins are allowed, so an arbitrary website can't script it from a browser.
- Bounded bodies. 16 MB request-body cap, so a buggy or hostile local client can't push an unbounded payload through the fan-out and into SQLite.
- Path-traversal-proof ids. Session ids are validated (≤128 chars, ASCII alphanumeric plus
-_., no..) before touching the filesystem,%2e%2e%2fincluded, since axum decodes first.
view / triage / operator, plus provider for a headless node), and is designed to be reachable over a Tailscale tailnet. Your remote client talks to the hub; the hub talks to claudemon over loopback. See the internals page.what's stable
Straight answer: work{spacer} is alpha and this API is not versioned yet. Here's what that means in practice, so you can judge the risk yourself rather than guess.
what you can lean on
- Route paths and verbs. The table above is the daemon's whole surface and it's been stable in shape.
/sessions,/events,/message,/approve, and/answerare load-bearing for three shipped clients. - Additive fields. New session fields land with serde defaults and back-compat on purpose, so old rows and old clients keep deserializing. You'll see fields appear; you shouldn't see them vanish.
- Tagged unions stay tagged.
pending.kindand conversationkindare discriminators; new variants get added to them. - The enum values above.
mode,transport, and the signal names are the wire vocabulary every client already depends on.
what to expect to move
- New
kindvalues, on both unions. Treat an unknown kind as "render generically", never as an error. - New
mode-adjacent state. Compaction and background-subagent tracking both arrived as new fields rather than new modes; expect more of that. - Provider-specific corners. The managed-spawn options track four upstream CLIs that change independently of us.
- Anything not on this page. If you found it by reading the source, it isn't a promise.
writing a client that survives
- Ignore unknown fields and unknown kinds. Don't use a strict deserializer that errors on extras.
- Pin a version you tested against and read the changelog before upgrading the daemon.
- Prefer
/messageover/input, and/approveover/decide. The opinionated endpoints are the ones we keep working; the raw ones expose upstream shapes that shift. - Read
apps/tuiwhen a case is unclear. It's a real client against this exact API, and it gets updated with the daemon.
If you build something, open an issue. The fastest way for this surface to become a versioned, frozen contract is for there to be clients whose breakage would matter.