HTTP API Reference
ccmuxd (the ccmux daemon) exposes a small HTTP + JSON API. It’s how the
ccmux TUI talks to a remote machine over Tailscale, and it’s the integration
surface for mobile clients — like the Moshi app —
that want to list, attach to, and act on agent sessions without shelling in.
This page is the contract. It’s generated from cmd/ccmuxd/main.go (routes)
and internal/daemon/protocol.go (the request/response types) in the
ccmux repo. If the running daemon disagrees
with this page, the daemon wins — please file an issue.
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.
Spotted an error or something out of date? Edit this page on GitHub.