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.
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.
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 / validation failure |
401 |
invalid or expired pairing token (/v1/pair only) |
404 |
not found — or this daemon predates the endpoint |
405 |
wrong HTTP method |
500 |
server / tmux / scaffold failure |
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/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 session this daemon manages, with daemon-derivedstate(active/idle/needs_input/error/unknown).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.POST /v1/sessions/bare(NewBareSessionRequest) →NewBareSessionResponse. A shell-only session (no project, no scaffold).POST /v1/sessions/NAME/kill→204. Emits akilledSSE event.POST /v1/sessions/NAME/rename(RenameRequest) →SessionState.NAMEis the current name; the body carries the new one.POST /v1/sessions/NAME/send-keys(SendKeysRequest) →204. Passed through totmux send-keys(e.g. type a reply +\n).GET /v1/sessions/NAME/preview?lines=N→PreviewResponse. Last N lines of the active pane as plain text (ANSI stripped).Nis1..200, default24. 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.
InsecureSkipVerify is set (no Origin check). 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.).
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).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.”)
Live updates — GET /v1/events (SSE)
text/event-stream. Each data: frame is a JSON SessionEvent; kind is
created / killed / state_change / needs_input. Heartbeats: : connected on open, : ping every 20s (comment lines starting with : are
ignorable). An event: drops / data: N frame means you missed N events and
should re-fetch /v1/sessions to 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”). 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.
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"`
Agent string `json:"agent,omitempty"`
}
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"`
}
type UsageSummary struct {
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 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"`
}
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:. - project names (for
POST /v1/projects) must be a single non-hidden path segment. - 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? Edit this page on GitHub.