Cmd+Ctrl Daemon Protocol Specification
Version: 1.0 Status: Stable Last updated: February 2026
This document defines the WebSocket protocol between a Cmd+Ctrl daemon and the Cmd+Ctrl backend server. Any program that implements this protocol can act as a daemon, enabling its agent to be controlled via the Cmd+Ctrl web, iOS, and Android clients.
Overview
A daemon is a long-running process on a developer's workstation that:
- Connects outbound to the Cmd+Ctrl server via WebSocket
- Receives task instructions from the server (originating from a user's client)
- Runs an AI agent (or any tool) to process those instructions
- Streams progress and results back to the server
- Serves message history on demand
The server never connects inbound to the daemon — all communication flows over a single outbound WebSocket initiated by the daemon.
┌─────────────────┐ WebSocket (outbound) ┌─────────────────┐
│ │ ────────────────────────────▶ │ │
│ Daemon │ │ Cmd+Ctrl │
│ (your machine) │ ◀──────────────────────────── │ Server │
│ │ JSON messages │ │
└────────┬────────┘ └────────┬────────┘
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│ Agent │ │ Clients │
│ (LLM, │ │ (Web, iOS, │
│ tool) │ │ Android) │
└──────────┘ └──────────────┘Table of Contents
- Authentication
- Connection Lifecycle
- Message Format
- Server → Daemon Messages
- Daemon → Server Messages
- Event Types
- Session ID Architecture
- Message Storage
- Task Lifecycle
- Optional Capabilities
Authentication
Device Registration
Before connecting, a daemon must register with the server using the device authorization flow (similar to GitHub CLI):
- Daemon →
POST /api/devices/code— requests a verification code - Server responds with a verification URL and code
- User opens the URL in their browser, enters the code, and authenticates
- Server issues a refresh token to the daemon (via polling or callback)
- Daemon stores the token locally (e.g.,
~/.cmdctrl-myagent/credentials, chmod 600)
Connection Headers
When opening the WebSocket, the daemon must send these headers:
| Header | Required | Description |
|---|---|---|
Authorization | Yes | Bearer <refresh_token> |
X-Device-ID | Yes | Device ID assigned during registration |
X-Agent-Type | No | Your agent type identifier (e.g., claude_code, my_custom_agent). Informational — the server uses the agent type from device registration. |
X-Daemon-Version | Yes | Semantic version of your daemon (e.g., 1.0.0) |
The agent type is set during device registration (the agentType field in the device code request). It is a free-form snake_case string that appears in the UI and is used to construct canonical session IDs. The X-Agent-Type header is informational and may be used for logging, but the server reads the authoritative value from the device's registration record.
Connection Lifecycle
1. Connect
Open a WebSocket to wss://<server>/ws/daemon with the headers above.
2. Send Initial Status
Immediately after the connection opens, send two messages:
{"type": "status", "running_tasks": []}{"type": "report_sessions", "sessions": []}The running_tasks array should list any task IDs that are still running from a previous connection (after a reconnect). For a fresh start, send an empty array.
The report_sessions message reports all sessions the daemon knows about. Send an empty array if there are none.
3. Ping/Pong Keepalive
The server sends {"type": "ping"} periodically. Respond immediately with {"type": "pong"}. The server will disconnect daemons that don't respond to pings.
Additionally, send a WebSocket-level ping frame every 30 seconds to detect dead connections.
4. Reconnection
If the connection drops, reconnect with exponential backoff:
- Start at 1 second
- Double on each failure
- Cap at 30 seconds
- Reset to 1 second on successful connection
Message Format
All messages are JSON objects with a type field that determines the message schema. Messages are sent as WebSocket text frames.
Server → Daemon Messages
These are messages the server sends to your daemon. Your daemon must handle all required messages.
ping (Required)
{"type": "ping"}Respond with {"type": "pong"}.
task_start (Required)
Start a new task. The user has created a session and sent their first message.
{
"type": "task_start",
"task_id": "device123:my_agent:PENDING-abc123",
"instruction": "Fix the authentication bug in login.py",
"project_path": "/home/user/myproject"
}| Field | Type | Description |
|---|---|---|
task_id | string | Canonical session ID (see Session ID Architecture) |
instruction | string | The user's message / instruction |
project_path | string? | Optional working directory hint |
On receiving this, your daemon should:
- Start your agent with the given instruction
- Generate a native session ID (any unique string your agent uses to identify sessions)
- Send a
SESSION_STARTEDevent with the native session ID - Stream progress events as the agent works
- Send
TASK_COMPLETE,WAIT_FOR_USER, orERRORwhen done
task_resume (Required)
Continue an existing session. The user has sent a follow-up message.
{
"type": "task_resume",
"task_id": "device123:my_agent:native-session-id",
"session_id": "native-session-id",
"message": "Use the PostgreSQL approach instead",
"project_path": "/home/user/myproject"
}| Field | Type | Description |
|---|---|---|
task_id | string | Canonical session ID |
session_id | string | Your agent's native session ID |
message | string | The user's follow-up message |
project_path | string? | Optional working directory hint |
task_cancel (Required)
Stop a running task.
{
"type": "task_cancel",
"task_id": "device123:my_agent:native-session-id"
}Your daemon should stop the agent and clean up resources. No response message is expected (the task simply stops producing events).
get_messages (Required)
Retrieve message history for a session. The daemon is the source of truth for message content.
{
"type": "get_messages",
"request_id": "req-uuid-123",
"session_id": "native-session-id",
"limit": 50,
"before_uuid": "msg-uuid-oldest-loaded",
"after_uuid": "msg-uuid-newest-loaded"
}| Field | Type | Description |
|---|---|---|
request_id | string | Echo this back in your response |
session_id | string | Native session ID |
limit | number | Max messages to return |
before_uuid | string? | Cursor: return messages older than this UUID |
after_uuid | string? | Cursor: return messages newer than this UUID |
Respond with a messages response (see Daemon → Server Messages).
watch_session
Sent when a user opens a session in the UI. The daemon should track which sessions are being watched and push session_activity updates when new activity occurs.
{
"type": "watch_session",
"session_id": "native-session-id",
"file_path": "/home/user/.my-agent/sessions/abc.json"
}unwatch_session
Sent when a user closes a session in the UI. Stop monitoring and pushing updates.
{
"type": "unwatch_session",
"session_id": "native-session-id"
}version_status (Optional)
Server notification about daemon version requirements.
{
"type": "version_status",
"status": "update_available",
"your_version": "1.0.0",
"min_version": "0.9.0",
"recommended_version": "1.1.0",
"latest_version": "1.1.0",
"changelog_url": "https://github.com/example/daemon/releases",
"message": "Bug fixes and performance improvements"
}| Field | Required | Description |
|---|---|---|
status | Yes | One of current, update_available, update_required |
your_version | Yes | The version you reported via X-Daemon-Version |
min_version | No | Minimum supported version (present when update_required) |
recommended_version | No | Recommended version to upgrade to |
latest_version | No | Latest available version |
changelog_url | No | URL to release notes |
message | No | Human-readable message |
Status values:
current– you're up to dateupdate_available– newer version exists (informational)update_required– your version is below the minimum; server will disconnect
Auto-update behavior
Daemons built on @cmdctrl/daemon-sdk v1.1.0+ act on version_status automatically:
update_available– the SDK runsnpm install -g <package>@latestonce the daemon is idle (no running tasks), then respawns itself.update_required– the SDK updates immediately, even if tasks are running, because the server will disconnect anyway.current– no action.- Windows is skipped (global
npm install -gpermissions are unreliable); the user is shown a manual install command via the/api/devices/version-statusbanner instead.
To opt out, pass autoUpdate: false in DaemonClientOptions. The SDK still surfaces the status to the user-facing banner; only the install step is suppressed.
Daemons that don't use the SDK (e.g. @cmdctrl/claude-code) must implement this contract themselves: defer-on-busy for update_available, immediate for update_required, skip on Windows, and respect the user's opt-out preference.
Daemon → Server Messages
These are messages your daemon sends to the server.
pong (Required)
Response to ping.
{"type": "pong"}status (Required)
Report which tasks are currently running. Send on connect and whenever the set of running tasks changes.
{
"type": "status",
"running_tasks": ["device123:my_agent:session-1", "device123:my_agent:session-2"]
}event (Required)
Report task progress or completion. This is the primary message type for communicating agent activity.
{
"type": "event",
"task_id": "device123:my_agent:PENDING-abc123",
"event_type": "SESSION_STARTED",
"session_id": "native-session-id"
}The event_type field determines which additional fields are expected. See Event Types.
messages (Required)
Response to a get_messages request.
{
"type": "messages",
"request_id": "req-uuid-123",
"session_id": "native-session-id",
"messages": [
{
"uuid": "msg-uuid-1",
"role": "USER",
"content": "Fix the auth bug",
"timestamp": "2026-02-19T10:00:00Z"
},
{
"uuid": "msg-uuid-2",
"role": "AGENT",
"content": "I've identified the issue in login.py...",
"timestamp": "2026-02-19T10:00:15Z"
}
],
"has_more": false,
"oldest_uuid": "msg-uuid-1",
"newest_uuid": "msg-uuid-2"
}| Field | Type | Description |
|---|---|---|
request_id | string | Must match the request |
session_id | string | Native session ID |
messages | array | Array of MessageEntry objects |
has_more | boolean | True if there are older messages beyond what was returned |
oldest_uuid | string? | UUID of the oldest message in this response |
newest_uuid | string? | UUID of the newest message in this response |
error | string? | Error message if retrieval failed |
MessageEntry:
| Field | Type | Description |
|---|---|---|
uuid | string | Unique message identifier (UUID v4 recommended) |
role | string | USER, AGENT, or SYSTEM |
content | string | Message text (Markdown supported) |
timestamp | string | ISO 8601 timestamp |
report_sessions (Required)
Report all sessions the daemon knows about. Sent on connect and periodically thereafter (e.g., every 30 seconds) so the server discovers new sessions started outside of Cmd+Ctrl.
{
"type": "report_sessions",
"sessions": [
{
"session_id": "native-session-id",
"slug": "fix-auth-bug",
"title": "Fix authentication bug",
"project": "/home/user/myproject",
"project_name": "myproject",
"file_path": "/home/user/.my-agent/sessions/abc.json",
"last_message": "Done! The auth bug is fixed.",
"last_activity": "2026-02-19T10:30:00Z",
"is_active": false,
"message_count": 5,
"cli_user_title": "Reprice"
}
]
}Send "sessions": [] if there are no sessions to report.
| Field | Type | Description |
|---|---|---|
cli_user_title | string? | Optional. Live value of an in-CLI rename (e.g. Claude Code's /rename). When present, it slots above the AI-generated title in display precedence – above the daemon's title and below an explicit Cmd+Ctrl rename. Omit (or send empty string) when no CLI rename has been observed for the session. Additive: pre-existing daemons that never set this field continue to work unchanged. |
report_slash_commands (Optional)
Report the slash commands your agent accepts, so the app's composer can autocomplete them. Send on connect and again whenever a set changes. Omit this message entirely if your agent has no concept of slash commands – clients render a composer with no autocomplete menu, which is the correct behaviour, not an error.
{
"type": "report_slash_commands",
"sets": [
{
"project": "/home/user/myproject",
"commands": [
{ "name": "compact", "description": "Summarize the conversation to free up context" },
{ "name": "rename", "description": "Rename this session", "usage_hint": "<title>" },
{ "name": "release-notes" }
]
}
]
}| Field | Type | Description |
|---|---|---|
sets[].project | string | Absolute project path the commands apply to. Matched against the project field of your report_sessions entries. |
sets[].commands[].name | string | Command name without the leading slash. |
sets[].commands[].description | string? | Short summary shown beside the name. Omit when you only know the command by name. |
sets[].commands[].usage_hint | string? | Argument shape, e.g. <title>. |
Each message is a full replacement of everything the daemon knows, not a delta. Keying by project rather than by session is deliberate: the command set is a property of the project and the user's environment, so repeating it inside every report_sessions tick would multiply an identical payload by the session count.
Only report commands you have verified end to end. Users send messages through the daemon's non-interactive path, which is not the same environment as an interactive terminal – a command the agent CLI advertises may confirm success and then silently do nothing, which is worse than never offering it. Enumerate from your agent, then subtract what you have empirically found not to survive; do not maintain a hardcoded allow-list, or the user's own commands and skills will never appear.
session_activity
Report that a watched session has new activity. Send this when a session tracked via watch_session changes.
{
"type": "session_activity",
"session_id": "native-session-id",
"file_path": "/home/user/.my-agent/sessions/abc.json",
"last_message": "Here's the fix...",
"message_count": 6,
"is_completion": true,
"last_activity": "2026-02-19T10:35:00Z"
}| Field | Type | Description |
|---|---|---|
is_completion | boolean | True when the last message is from the agent (triggers push notifications) |
user_message_uuid | string? | UUID of the user message that triggered this activity |
Event Types
Events are sent via the event message type. The event_type field determines the schema.
SESSION_STARTED (Required)
Must be the first event sent for a new task. This tells the server the agent's native session ID so the PENDING placeholder can be resolved.
{
"type": "event",
"task_id": "device123:my_agent:PENDING-abc123",
"event_type": "SESSION_STARTED",
"session_id": "my-native-session-id-12345"
}After this event, the server updates the canonical session ID from device123:my_agent:PENDING-abc123 to device123:my_agent:my-native-session-id-12345. All subsequent events for this task should use the new canonical ID.
PROGRESS (Optional, Recommended)
Report what the agent is currently doing. Displayed as a status indicator in the UI.
{
"type": "event",
"task_id": "device123:my_agent:session-id",
"event_type": "PROGRESS",
"action": "Reading",
"target": "login.py"
}| Field | Type | Description |
|---|---|---|
action | string | Short verb (e.g., "Reading", "Writing", "Thinking", "Searching") |
target | string | What the action applies to (e.g., filename, URL, description) |
OUTPUT (Optional)
Verbose streaming output. Displayed in the "verbose" view for users who want to see raw agent output.
{
"type": "event",
"task_id": "device123:my_agent:session-id",
"event_type": "OUTPUT",
"output": "Analyzing the authentication flow...\n",
"user_message_uuid": "uuid-of-user-message"
}| Field | Type | Description |
|---|---|---|
output | string | Raw text output from the agent |
user_message_uuid | string? | Associates output with the user message that triggered it |
WAIT_FOR_USER (Required for interactive agents)
The agent needs user input before continuing. The session becomes "awaiting reply" in the UI.
{
"type": "event",
"task_id": "device123:my_agent:session-id",
"event_type": "WAIT_FOR_USER",
"prompt": "Which database should I use?",
"context": "I've analyzed the requirements and there are two options...",
"options": [
{"label": "PostgreSQL"},
{"label": "SQLite"}
],
"result": "Which database should I use?\n\nI've analyzed the requirements..."
}| Field | Type | Description |
|---|---|---|
prompt | string | The question being asked |
context | string? | Additional context for the question |
options | array? | Suggested response options (each with a label field) |
result | string | Full message content displayed in the conversation |
user_message_uuid | string? | UUID of the triggering user message |
TASK_COMPLETE (Required)
The agent has finished processing and is not waiting for user input.
{
"type": "event",
"task_id": "device123:my_agent:session-id",
"event_type": "TASK_COMPLETE",
"result": "I've fixed the authentication bug in login.py. The issue was...",
"user_message_uuid": "uuid-of-user-message"
}| Field | Type | Description |
|---|---|---|
result | string | The agent's final response (Markdown supported) |
user_message_uuid | string? | UUID of the triggering user message |
ERROR (Required)
An error occurred during task execution.
{
"type": "event",
"task_id": "device123:my_agent:session-id",
"event_type": "ERROR",
"error": "Agent process crashed: exit code 1"
}Session ID Architecture
Cmd+Ctrl uses a three-part canonical session ID format:
<device_id>:<agent_type>:<native_session_id>Example: macbook-pro:claude_code:sess_abc123
| Part | Source | Description |
|---|---|---|
device_id | Registration | Assigned when the daemon registers |
agent_type | Daemon header | The X-Agent-Type value |
native_session_id | Your agent | Whatever ID your agent uses internally |
The PENDING Flow
When a user creates a new session, the server doesn't yet know your agent's native session ID. It uses a placeholder:
macbook-pro:claude_code:PENDING-uuid-from-serverYour daemon receives a task_start with this PENDING task ID. Once your agent starts and you know its native session ID, send a SESSION_STARTED event with the real ID. The server then migrates:
BEFORE: macbook-pro:claude_code:PENDING-abc123
AFTER: macbook-pro:claude_code:sess_real_id_456For task_resume, the task_id already contains the resolved native session ID.
Message Storage
The daemon is the source of truth for message history. The server stores only metadata (title, status, timestamps) — not message content. When a client needs to display messages, the server sends a get_messages request to the daemon, which returns the messages.
Your daemon must:
- Store all messages (user and agent) with UUIDs and timestamps
- Support cursor-based pagination via
before_uuid/after_uuid - Respond to
get_messagesrequests even when the agent is idle
How you store messages is up to you — JSONL files, SQLite, in-memory, etc. The only requirement is the response format.
Task Lifecycle
New Task (task_start)
Client creates session → Server sends task_start → Daemon starts agent
→ Daemon sends SESSION_STARTED (with native session ID)
→ Daemon sends PROGRESS events (optional)
→ Daemon sends OUTPUT events (optional)
→ Daemon sends TASK_COMPLETE or WAIT_FOR_USER or ERROR
→ Daemon updates status (removes task from running_tasks)Follow-up Message (task_resume)
Client sends message → Server sends task_resume → Daemon resumes agent
→ Daemon sends PROGRESS events (optional)
→ Daemon sends TASK_COMPLETE or WAIT_FOR_USER or ERROR
→ Daemon updates statusCancellation
Client cancels → Server sends task_cancel → Daemon stops agent
→ Daemon updates status (removes task from running_tasks)Optional Capabilities
These features improve the user experience but are not required for basic functionality.
Session Watching
When the server sends watch_session, a user has opened that session in the UI. Monitor the session for changes and send session_activity messages to push updates.
Implementation Checklist
Minimum Viable Daemon
- [ ] Register with server (device auth flow)
- [ ] Connect via WebSocket with required headers
- [ ] Send
statusandreport_sessionson connect - [ ] Handle
ping→ respond withpong - [ ] Handle
task_start→ start agent, sendSESSION_STARTED, thenTASK_COMPLETE/WAIT_FOR_USER/ERROR - [ ] Handle
task_resume→ resume agent, send completion event - [ ] Handle
task_cancel→ stop agent - [ ] Handle
get_messages→ return message history - [ ] Handle
watch_session/unwatch_session→ track watched sessions, pushsession_activity - [ ] Send
statusupdates when running tasks change - [ ] Reconnect with exponential backoff
Enhanced Daemon
- [ ] Send
PROGRESSevents for live status updates - [ ] Send
OUTPUTevents for verbose streaming - [ ] Report sessions with full metadata via
report_sessions - [ ] Report verified slash commands via
report_slash_commands - [ ] Handle
version_statusfor update notifications