HTTP API Reference
The ccmux daemon exposes an HTTP and JSON API for listing sessions, reading project data, and building remote clients. This reference covers connection setup, the trust model, endpoints, and response types.
The routes and types are maintained in the ccmux source. Older daemon versions may not support every endpoint documented here; check the health endpoint for the running version.
Transport & reachability
The same routes are served over two transports:
| Transport | Address | Default |
|---|---|---|
| Local Unix socket | ~/.local/state/ccmux/ccmuxd.sock (mode 0600) |
always on |
| Tailnet HTTP | http://TAILNET-IPv4:PORT (port default 7474) |
off |
To reach a daemon from another device, on the daemon host: set
daemon.listen_tailnet = true (optionally daemon.tailnet_port) in
~/.config/ccmux/config.toml and restart ccmuxd. The bind address is the
host’s tailscale ip -4. If Tailscale isn’t running, the tailnet listener
silently doesn’t start (only the Unix socket is served).
- All paths are under
/v1/. There is no/v2. - Request/response bodies are
application/json— except/v1/events(text/event-stream) and the attach endpoint (a WebSocket upgrade). - No TLS. Tailscale’s WireGuard tunnel is the encryption + identity boundary; the HTTP listener is plain HTTP bound to the tailnet IP.
- Request bodies are capped at 64 KiB. A larger body, or anything but
whitespace after the JSON value, is a
400. GETendpoints answer405to any other method;/v1/healthalso takesHEAD.
Authentication & trust model — read this first
There is no application-level authentication on the HTTP API. No bearer
token, no API key, no per-request signature, no IP allowlist. The trust
boundary is your Tailscale tailnet: any host that can reach the daemon’s
100.x.x.x:7474 can call every endpoint — list / create / kill / rename /
send-keys, attach to a full interactive terminal, and read
notes / conversations / usage.
Secure this with Tailscale ACLs, not app-level auth. Treat anything that can route to the daemon’s tailnet IP as fully trusted.
Browsers are refused. The tailnet listener answers 403 to any request
with an Origin or Sec-Fetch-* header. Native clients (the CLI and TUI,
ccmux-mcp, the mobile apps) send neither; browsers always do. Without this,
a web page open on any tailnet device could send keys to a session. Don’t
build a browser client against this API.
Two endpoints validate per request, and only to bootstrap device trust + push — not to protect the API:
POST /v1/pairrequires a valid, single-use pairing token plus a parseable SSH public key. Redeeming it installs that key into the host’s~/.ssh/authorized_keys.POST /v1/pair-tokenis Unix-socket only (never on the tailnet) and additionally returns503unlesslisten_tailnetis on.
Error shape
Errors are plain text (text/plain): a single-line message, not a
JSON envelope. Check the status code, and read the body as a string for
the human message. Successful JSON responses are application/json.
| Code | Meaning |
|---|---|
200 |
OK (JSON body) |
204 |
OK, no body (kill / send-keys / device register) |
400 |
bad input: a bad session or project name, an unknown agent, a path that isn’t a directory, a body that doesn’t decode |
401 |
invalid or expired pairing token (/v1/pair only) |
403 |
a browser request on the tailnet listener, or /v1/devices for a key that never paired |
404 |
no such session or project path — or this daemon predates the endpoint |
405 |
wrong HTTP method |
409 |
the name is taken: a session name you passed runs in another directory, both of a project’s session names do, or a rename targets an existing session |
500 |
server / tmux / scaffold failure |
502 |
the session started, but its agent exited at once (not installed, or broken) |
503 |
feature unavailable (e.g. /v1/pair-token with listen_tailnet off) |
The API evolves by adding JSON fields (all optional fields use omitempty)
and adding routes. Treat a 404 on a whole endpoint as “this daemon is
older than that feature” and degrade gracefully.
Endpoint summary
| Method | Path | Response |
|---|---|---|
GET |
/v1/health |
HealthInfo |
GET |
/v1/peers |
[]PeerInfo |
GET |
/v1/sessions |
[]SessionState |
POST |
/v1/sessions |
SessionState (create-or-attach) |
POST |
/v1/sessions/bare |
NewBareSessionResponse |
POST |
/v1/sessions/NAME/kill |
204 |
POST |
/v1/sessions/NAME/rename |
SessionState |
POST |
/v1/sessions/NAME/send-keys |
204 |
GET |
/v1/sessions/NAME/preview |
PreviewResponse |
GET |
/v1/sessions/NAME/attach |
WebSocket (interactive PTY) |
GET |
/v1/projects |
[]ProjectInfo |
POST |
/v1/projects |
NewProjectResponse |
GET |
/v1/conversations |
[]Conversation |
GET |
/v1/usage?window=… |
AgentUsage |
GET |
/v1/notes?project=…[&file=…] |
[]NoteEntry or NoteContent |
GET |
/v1/notes/search?project=…&q=… |
[]SearchHit |
GET |
/v1/models[?refresh=true] |
Catalog |
GET |
/v1/events |
SSE stream of SessionEvent |
POST |
/v1/pair-token (Unix only) |
PairTokenResponse |
POST |
/v1/pair |
PairResponse |
POST |
/v1/devices |
204 |
POST |
/v1/devices/test |
204 |
(Where a path shows NAME it’s the tmux session name path segment,
i.e. /v1/sessions/{name}/kill.)
Endpoint details
Sessions
GET /v1/sessions→[]SessionState. Every tmux session on the daemon’s host, with daemon-derivedstate(active/idle/needs_input/error/unknown).projectis the project the session’s directory belongs to,""outside the projects root.agentis the agent ccmux tagged the session with when it started it. A session taggedshell, or made outside ccmux, reportsshellunless an agent is running in its foreground right now.prompt_countandseensurvive a daemon restart (ccmux update, a brew upgrade) and a rename done straight in tmux. The daemon keeps them on the session itself, as the tmux user options@ccmux_seen,@ccmux_promptsand@ccmux_state, and reads them back on its first look. If the session’s state changed while no daemon was watching, it’s judged as it stands instead: waiting for input means unreviewed. A plain shell with nothing to remember gets no record.POST /v1/sessions(NewSessionRequest,projectrequired) →SessionState. Create-or-attach a project-bound agent session (idempotent on tmux name).pathdefaults toPROJECTS_ROOT/PROJECTon the daemon host. Runsagentif given, else the project’s recorded agent, else Claude. If the session already runs in that directory, you get its real state and nothing starts. Withoutname, the session is the project’s own, found by its directory:c-PROJECT, orc-PROJECT-TAGwhen a same-named project elsewhere already hasc-PROJECT(TAGis five letters derived from the full path). Errors:400for a bad name, an unknownagentor a path that isn’t a directory;404if the project path doesn’t exist;409if thenameyou passed runs in another directory, or the tagged name is taken too;502if the agent exits right after starting.POST /v1/sessions/bare(NewBareSessionRequest) →NewBareSessionResponse. A session not tied to a project (no scaffold). An emptyagentfalls back tosessions.default_agent, then$SHELL;"shell"means no agent, and anything else must be a known agent ID. An emptypathissessions.default_diror$HOMEon the daemon host.POST /v1/sessions/NAME/kill→204. Emits akilledSSE event.404if there’s no such session.POST /v1/sessions/NAME/rename(RenameRequest) →SessionState.NAMEis the current name; the body carries the new one.404if there’s no such session,409if another session already has the new name.POST /v1/sessions/NAME/send-keys(SendKeysRequest) →204. Passed through totmux send-keys(e.g. type a reply +\n). Keys go to the pane the session’s agent runs in, even when another window or split is active, so a reply meant for the agent never lands in a shell.404if there’s no such session.GET /v1/sessions/NAME/preview?lines=N→PreviewResponse. The last N lines of the agent’s pane (the onesend-keystypes into) as plain text, ANSI stripped: exactly N when the pane has that many, not counting blank rows below the last output. Default24; anything above500is clamped to500. A lightweight peek without opening the attach socket.
Interactive terminal — GET /v1/sessions/NAME/attach (WebSocket)
Upgrade to a WebSocket bridged to a real tmux attach-session in a PTY: a
true interactive terminal (live output, input, resize) without ssh/mosh.
Uses coder/websocket framing.
- client → server, binary frame: raw stdin bytes (keystrokes).
- client → server, text frame: JSON
{"cols":N,"rows":N}to resize. - server → client, binary frame: raw PTY output bytes.
Initial PTY size is 80x24 until the first resize. The server pings every
25s with a 10s deadline — answer pongs or expect a teardown.
A missing session is a 404 with no upgrade, and a request with an Origin
header is refused. Input goes to the pane the terminal shows, as with any
tmux client. Closing the socket only detaches; the tmux session keeps
running. Prefer this over polling /preview for an interactive view.
Projects
GET /v1/projects→[]ProjectInfo. Projects discovered under the daemon’s projects root, tagged with its hostname.POST /v1/projects(NewProjectRequest,namerequired) →NewProjectResponse. Creates a new project (directory only — noCLAUDE.md/git) and starts an agent session.namemust be a single non-hidden path segment (no/,\, no leading.). Withoutagent, an existing directory’s recorded agent runs, else Claude. If the project’s session already runs, it’s returned and nothing starts. The session is named as forPOST /v1/sessionswithoutname, so ac-NAMErunning in another directory gets this projectc-NAME-TAGinstead of a conflict.409if both names are taken by sessions in other directories,502if the agent exits right after starting.
Conversations, usage, notes
GET /v1/conversations→[]Conversation. Past agent transcripts, most-recent first;idis the agent’s own UUID (its--resumeid).GET /v1/usage?window=DURATION→AgentUsage. Per-agent token + cost over a rolling window (Go duration like2h,24h; default5h, capped at744h), plus Claude’s current 5-hour block inclaude_block.windowonly sizes the per-agent summaries.claude_blockis the block the dashboard’s quota bar and “resets in” line show, computed the way ccusage does it. A block starts at the UTC hour its first message fell in and lastsblock_seconds(18000); a message after that opens the next one.reset_atis when the quota resets. Theclaudesummary next to it covers the plain rolling window, so its prompts, tokens and cost differ, and it has no reset time. Withactive: falseno block is running:startandreset_atare left out and every count is 0. Daemons older than v0.6.3, or ones that can’t read the transcripts, leaveclaude_blockout.ccmux usageprints the same block, and the MCP server’sget_usagereturns it.
"claude_block": {
"active": true, "start": "2026-09-28T13:00:00Z", "reset_at": "2026-09-28T18:00:00Z",
"block_seconds": 18000, "prompts": 6, "messages": 14,
"input_tokens": 7200, "output_tokens": 2100, "cache_creation_tokens": 24000, "cache_read_tokens": 540000,
"estimated_cost": 0.31
}
GET /v1/notes?project=NAME→[]NoteEntry; with&file=REL→NoteContent.filemust be a project-relative.mdpath, no...GET /v1/notes/search?project=NAME&q=QUERY→[]SearchHit. Ripgrep-backed. (Older daemons404— treat as “search unavailable.”)
Models
GET /v1/models→Catalog. The Claude model catalog behind the TUI’s model picker andccmux agents models. The daemon asksclaude -pfirst (works for subscription and API users), then Anthropic’s Models API ifANTHROPIC_API_KEYis set. The result is always merged with the curated list that ships in the binary, so it’s never empty. Refreshed weekly in the background;sourceisclaude-cli,apiorfallback.?refresh=truere-runs discovery before answering. Concurrent refreshes share one run, and discovery runs at most once per 10 minutes: a request inside that window gets the last result. A failed refresh returns the cached catalog.
Live updates — GET /v1/events (SSE)
text/event-stream. Each data: frame is a JSON SessionEvent; kind is
created / killed / state_change / needs_input. session is the full
SessionState as of the event, the same fields GET /v1/sessions returns
(agent, project, created, attached, prompt_count, …). A killed
event carries the session as last seen, with its last state.
killedis sent however the session ended. A rename arrives askilledfor the old name, thencreatedfor the new one.state_changeis also sent when the state stays the same but a client attaches or detaches, or the agent in the foreground changes.- After a daemon restart, each session gets one
state_changewith its current state. Itsprompt_countandseencome from the record kept on the session, not from zero. - Heartbeats:
: connectedon open,: pingevery 20s (comment lines starting with:are ignorable). - If you get an
event: drops/data: Nframe, you missed N events; re-fetch/v1/sessionsto resync.
Health & discovery
GET /v1/health→HealthInfo. Liveness + identity probe.GET /v1/peers→[]PeerInfo. Every tailnet peer + whether each runs ccmuxd (returns[], not500, when Tailscale is absent).
Pairing & push (mobile)
Optional flow for native push (APNs/iOS, FCM/Android) and installing an SSH
key so the device can ssh/mosh attach. Not required to use the
read/act endpoints over the tailnet.
POST /v1/pair-token(Unix-socket only) →PairTokenResponse. Mint a one-time token (128-bit hex, single-use, 5-min TTL) + accmux://pair?…deep link.503iflisten_tailnetis off.POST /v1/pair(PairRequest) →PairResponse. Redeem a token: install the device’s SSH public key, optionally register a push token inline.401on invalid/expired token. Reachable on the tailnet.POST /v1/devices(RegisterDeviceRequest) →204. Register/refresh a push token on an already-paired host. Device identified by its SSHpublic_key(stored only as a SHA-256 hash).providerisapns(default) orfcm; APNs needsenv=development/production, FCM needs emptyenv.POST /v1/devices/test({"public_key":"…"}) →204. Verification push to the device for that key.
Pushes fire on two transitions: a session entering needs_input, and
active → idle (“agent finished”), and only when that ends a real turn
(below). A push goes out only while no client is attached. The push’s
session id is local/SESSIONNAME. APNs and FCM are off by default and
need server-side config; FCM routing exists but Android delivery isn’t
wired up yet.
What counts as a turn
Every state change goes out on /v1/events. Only the end of a turn rings
the bell or sends a push, and a turn that ends in needs_input also bumps
prompt_count. A turn is the agent working, shown by something your own
typing can’t produce:
- a live spinner in the pane’s OSC title (a braille glyph that keeps changing);
- a pane body that shows a turn running, such as Claude Code’s
esc to interruptstatus line or another agent’s working footer; - for Claude Code, new output above its input box since the last poll;
- for other agents, the session reading as
active, until that agent has shown a spinner title in the session. The session remembers that in the tmux user option@ccmux_spinner, so typing into it after a daemon restart doesn’t notify either.
A resize, typing into a prompt, an agent starting up, or relaunching a
crashed one isn’t a turn. A Claude that crashes mid-turn leaves its last
frame on screen with the shell prompt under it; that reads as error, and
doesn’t ring or push.
After a daemon restart, each session is recorded as it stands: waiting for
input is needs_input, a body that shows a turn running is active. It
stays quiet until the daemon has seen it settle, with its pane unchanged for
a whole idle threshold, so a turn that was running during the restart isn’t
announced when it ends. The cost: a turn started within one idle threshold
of the restart isn’t announced either, unless the session’s agent is known
to show a spinner while it works and shows none. The full rules are under
“What counts as a turn” in the
daemon API spec.
Types
Copied from internal/daemon/protocol.go (Go structs with their JSON tags).
type SessionState struct {
Name string `json:"name"`
Host string `json:"host"` // "local" or a remote host name
Project string `json:"project"`
Path string `json:"path"` // session working directory
State string `json:"state"` // active|idle|needs_input|error|unknown
Attached bool `json:"attached"`
Windows int `json:"windows"`
Created time.Time `json:"created"`
LastChange time.Time `json:"last_change"`
PromptCount int `json:"prompt_count"` // turns that ended in needs_input; kept across restarts
Agent string `json:"agent,omitempty"` // agent tag, or "shell"
Seen bool `json:"seen"` // false: changed since anyone last looked; kept across restarts
}
type HealthInfo struct {
OK bool `json:"ok"`
Hostname string `json:"hostname"`
Version string `json:"version"`
Sessions int `json:"sessions"`
SleepMode string `json:"sleep_mode"` // off|safe|dangerous|very_dangerous
}
type SessionEvent struct {
At time.Time `json:"at"`
Kind string `json:"kind"` // state_change|created|killed|needs_input
Session SessionState `json:"session"`
}
type NewSessionRequest struct {
Project string `json:"project"`
Path string `json:"path"` // defaults to PROJECTS_ROOT/PROJECT
Continue bool `json:"continue"` // start the agent with --continue
Name string `json:"name,omitempty"`
Agent string `json:"agent,omitempty"`
}
type NewBareSessionRequest struct {
Name string `json:"name,omitempty"`
Path string `json:"path,omitempty"`
Agent string `json:"agent,omitempty"`
}
type NewBareSessionResponse struct {
Session string `json:"session"`
Path string `json:"path"`
Host string `json:"host"`
}
type NewProjectRequest struct {
Name string `json:"name"`
Agent string `json:"agent,omitempty"`
}
type NewProjectResponse struct {
Session string `json:"session"`
Path string `json:"path"`
Host string `json:"host"`
}
type ProjectInfo struct {
Name string `json:"name"`
Host string `json:"host"`
Path string `json:"path"` // absolute on the daemon host
HasGit bool `json:"has_git"`
HasCM bool `json:"has_cm"`
HasAgents bool `json:"has_agents,omitempty"`
HasDocs bool `json:"has_docs"`
Agent string `json:"agent,omitempty"`
Modified time.Time `json:"modified"`
}
type PeerInfo struct {
Hostname string `json:"hostname"`
Addr string `json:"addr"` // tailnet IPv4
OS string `json:"os"`
Online bool `json:"online"`
RunsCCMuxd bool `json:"runs_ccmuxd"`
Port *int `json:"port,omitempty"`
}
type Conversation struct {
ID string `json:"id"` // agent UUID (its --resume id)
Agent string `json:"agent"`
Project string `json:"project,omitempty"`
Path string `json:"path,omitempty"`
Preview string `json:"preview,omitempty"`
Modified time.Time `json:"modified"`
}
type AgentUsage struct {
Claude UsageSummary `json:"claude"`
Codex UsageSummary `json:"codex"`
Antigravity UsageSummary `json:"antigravity"`
OpenRouter OpenRouterSpend `json:"openrouter"`
Others []OtherUsage `json:"others,omitempty"` // other agents with usage in the window
ClaudeBlock *ClaudeBlock `json:"claude_block,omitempty"` // Claude's 5-hour session block
}
type ClaudeBlock struct {
Active bool `json:"active"`
Start time.Time `json:"start,omitzero"` // hour-floored block start
ResetAt time.Time `json:"reset_at,omitzero"` // block end: the quota resets
BlockSeconds int `json:"block_seconds"`
Prompts int `json:"prompts"` // what the TUI's quota bar counts
Messages int `json:"messages"` // assistant responses
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationTokens int `json:"cache_creation_tokens"`
CacheReadTokens int `json:"cache_read_tokens"`
EstimatedCost float64 `json:"estimated_cost"` // USD
}
type UsageSummary struct {
CachedInputTokens int `json:"cached_input_tokens,omitempty"`
ReasoningTokens int `json:"reasoning_tokens,omitempty"`
CostAvailable *bool `json:"cost_available,omitempty"`
HasData bool `json:"has_data"`
WindowSeconds int `json:"window_seconds"`
Prompts int `json:"prompts"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
EstimatedCost float64 `json:"estimated_cost"` // USD
}
type OtherUsage struct {
Agent string `json:"agent"`
Usage UsageSummary `json:"usage"`
}
type OpenRouterSpend struct {
Enabled bool `json:"enabled"` // false: no OpenRouter key configured
ErrMsg string `json:"err_msg,omitempty"`
Usage float64 `json:"usage"` // USD spent on the key
Limit float64 `json:"limit"` // 0 = uncapped
Remaining float64 `json:"remaining"` // -1 when uncapped
IsFreeTier bool `json:"is_free_tier"`
}
type NoteEntry struct {
Rel string `json:"rel"`
Dir string `json:"dir"`
Display string `json:"display"`
Modified time.Time `json:"modified"`
}
type NoteContent struct {
Rel string `json:"rel"`
Content string `json:"content"`
}
type SearchHit struct {
Rel string `json:"rel"`
LineNum int `json:"line_num"`
Snippet string `json:"snippet"`
}
type PreviewResponse struct {
Lines int `json:"lines"`
Content string `json:"content"`
}
// GET /v1/models
type Catalog struct {
Models []Model `json:"models"`
FetchedAt time.Time `json:"fetched_at"`
Source string `json:"source"` // claude-cli|api|fallback
}
type Model struct {
ID string `json:"id"`
DisplayName string `json:"display_name,omitempty"`
Family string `json:"family,omitempty"` // opus|sonnet|haiku
MaxInput int `json:"max_input_tokens,omitempty"`
MaxOutput int `json:"max_tokens,omitempty"`
Capabilities map[string]bool `json:"capabilities,omitempty"`
Source string `json:"source"`
}
type RenameRequest struct{ Name string `json:"name"` }
type SendKeysRequest struct{ Keys string `json:"keys"` }
type PairRequest struct {
Token string `json:"token"`
PublicKey string `json:"public_key"`
DeviceToken string `json:"device_token,omitempty"`
APNsEnv string `json:"apns_env,omitempty"` // development|production
}
type PairResponse struct{ Hostname string `json:"hostname"`; Version string `json:"version"` }
type PairTokenResponse struct{ Token string `json:"token"`; URL string `json:"url"` }
type RegisterDeviceRequest struct {
Token string `json:"token"`
Env string `json:"env,omitempty"` // apns: development|production; fcm: empty
PublicKey string `json:"public_key"`
Provider string `json:"provider,omitempty"` // apns (default) | fcm
}
// POST /v1/devices/test body is { "public_key": "..." }
Validation rules to mirror client-side
- tmux session names must not contain
/,\,:,.or control characters, and must not start with$: tmux reads$1as a session ID, so it would act on whichever session has that ID. Names you create or rename to also can’t contain#. - project names (for
POST /v1/projects, andPOST /v1/sessionswithout apath) must be a single non-hidden path segment with no control characters. Apathmay start with~/(the daemon’s home). - notes file paths must be project-relative, no
.., ending in.md. - request bodies are capped at 64 KiB.
Examples
# Health (over the tailnet)
curl -s http://100.75.64.20:7474/v1/health | jq
# List sessions
curl -s http://100.75.64.20:7474/v1/sessions | jq
# Type a reply into a session and press Enter
curl -s -X POST http://100.75.64.20:7474/v1/sessions/c-auth/send-keys \
-H 'content-type: application/json' \
-d '{"keys":"yes, ship it\n"}'
# Peek at the last 40 lines of a pane
curl -s 'http://100.75.64.20:7474/v1/sessions/c-auth/preview?lines=40' | jq -r .content
# Subscribe to live events
curl -sN http://100.75.64.20:7474/v1/events
The Go reference client in
internal/daemon/client.go
is the canonical consumer and a useful map of method → endpoint.
Help improve ccmux
Found a bug, an unclear guide, or a translation that could read better? Contributions of any size are welcome. Open an issue or send a pull request.
Spotted an error or something out of date? Open an issue on GitHub.