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).

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:

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

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.

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

Conversations, usage, notes

"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
}

Models

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.

Health & discovery

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.

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 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

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.