Skip to content

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:

  1. Connects outbound to the Cmd+Ctrl server via WebSocket
  2. Receives task instructions from the server (originating from a user's client)
  3. Runs an AI agent (or any tool) to process those instructions
  4. Streams progress and results back to the server
  5. 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

Device Registration

Before connecting, a daemon must register with the server using the device authorization flow (similar to GitHub CLI):

  1. DaemonPOST /api/devices/code — requests a verification code
  2. Server responds with a verification URL and code
  3. User opens the URL in their browser, enters the code, and authenticates
  4. Server issues a refresh token to the daemon (via polling or callback)
  5. Daemon stores the token locally (e.g., ~/.cmdctrl-myagent/credentials, chmod 600)

Connection Headers

When opening the WebSocket, the daemon must send these headers:

HeaderRequiredDescription
AuthorizationYesBearer <refresh_token>
X-Device-IDYesDevice ID assigned during registration
X-Agent-TypeNoYour agent type identifier (e.g., claude_code, my_custom_agent). Informational — the server uses the agent type from device registration.
X-Daemon-VersionYesSemantic 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:

json
{"type": "status", "running_tasks": []}
json
{"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)

json
{"type": "ping"}

Respond with {"type": "pong"}.

task_start (Required)

Start a new task. The user has created a session and sent their first message.

json
{
  "type": "task_start",
  "task_id": "device123:my_agent:PENDING-abc123",
  "instruction": "Fix the authentication bug in login.py",
  "project_path": "/home/user/myproject"
}
FieldTypeDescription
task_idstringCanonical session ID (see Session ID Architecture)
instructionstringThe user's message / instruction
project_pathstring?Optional working directory hint

On receiving this, your daemon should:

  1. Start your agent with the given instruction
  2. Generate a native session ID (any unique string your agent uses to identify sessions)
  3. Send a SESSION_STARTED event with the native session ID
  4. Stream progress events as the agent works
  5. Send TASK_COMPLETE, WAIT_FOR_USER, or ERROR when done

task_resume (Required)

Continue an existing session. The user has sent a follow-up message.

json
{
  "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"
}
FieldTypeDescription
task_idstringCanonical session ID
session_idstringYour agent's native session ID
messagestringThe user's follow-up message
project_pathstring?Optional working directory hint

task_cancel (Required)

Stop a running task.

json
{
  "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.

json
{
  "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"
}
FieldTypeDescription
request_idstringEcho this back in your response
session_idstringNative session ID
limitnumberMax messages to return
before_uuidstring?Cursor: return messages older than this UUID
after_uuidstring?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.

json
{
  "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.

json
{
  "type": "unwatch_session",
  "session_id": "native-session-id"
}

version_status (Optional)

Server notification about daemon version requirements.

json
{
  "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"
}
FieldRequiredDescription
statusYesOne of current, update_available, update_required
your_versionYesThe version you reported via X-Daemon-Version
min_versionNoMinimum supported version (present when update_required)
recommended_versionNoRecommended version to upgrade to
latest_versionNoLatest available version
changelog_urlNoURL to release notes
messageNoHuman-readable message

Status values:

  • current — you're up to date
  • update_available — newer version exists (informational)
  • update_required — your version is below the minimum; server will disconnect

Daemon → Server Messages

These are messages your daemon sends to the server.

pong (Required)

Response to ping.

json
{"type": "pong"}

status (Required)

Report which tasks are currently running. Send on connect and whenever the set of running tasks changes.

json
{
  "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.

json
{
  "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.

json
{
  "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"
}
FieldTypeDescription
request_idstringMust match the request
session_idstringNative session ID
messagesarrayArray of MessageEntry objects
has_morebooleanTrue if there are older messages beyond what was returned
oldest_uuidstring?UUID of the oldest message in this response
newest_uuidstring?UUID of the newest message in this response
errorstring?Error message if retrieval failed

MessageEntry:

FieldTypeDescription
uuidstringUnique message identifier (UUID v4 recommended)
rolestringUSER, AGENT, or SYSTEM
contentstringMessage text (Markdown supported)
timestampstringISO 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.

json
{
  "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
    }
  ]
}

Send "sessions": [] if there are no sessions to report.

session_activity

Report that a watched session has new activity. Send this when a session tracked via watch_session changes.

json
{
  "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"
}
FieldTypeDescription
is_completionbooleanTrue when the last message is from the agent (triggers push notifications)
user_message_uuidstring?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.

json
{
  "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.

Report what the agent is currently doing. Displayed as a status indicator in the UI.

json
{
  "type": "event",
  "task_id": "device123:my_agent:session-id",
  "event_type": "PROGRESS",
  "action": "Reading",
  "target": "login.py"
}
FieldTypeDescription
actionstringShort verb (e.g., "Reading", "Writing", "Thinking", "Searching")
targetstringWhat 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.

json
{
  "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"
}
FieldTypeDescription
outputstringRaw text output from the agent
user_message_uuidstring?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.

json
{
  "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..."
}
FieldTypeDescription
promptstringThe question being asked
contextstring?Additional context for the question
optionsarray?Suggested response options (each with a label field)
resultstringFull message content displayed in the conversation
user_message_uuidstring?UUID of the triggering user message

TASK_COMPLETE (Required)

The agent has finished processing and is not waiting for user input.

json
{
  "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"
}
FieldTypeDescription
resultstringThe agent's final response (Markdown supported)
user_message_uuidstring?UUID of the triggering user message

ERROR (Required)

An error occurred during task execution.

json
{
  "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

PartSourceDescription
device_idRegistrationAssigned when the daemon registers
agent_typeDaemon headerThe X-Agent-Type value
native_session_idYour agentWhatever 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-server

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

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

  1. Store all messages (user and agent) with UUIDs and timestamps
  2. Support cursor-based pagination via before_uuid / after_uuid
  3. Respond to get_messages requests 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 status

Cancellation

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 status and report_sessions on connect
  • [ ] Handle ping → respond with pong
  • [ ] Handle task_start → start agent, send SESSION_STARTED, then TASK_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, push session_activity
  • [ ] Send status updates when running tasks change
  • [ ] Reconnect with exponential backoff

Enhanced Daemon

  • [ ] Send PROGRESS events for live status updates
  • [ ] Send OUTPUT events for verbose streaming
  • [ ] Report sessions with full metadata via report_sessions
  • [ ] Handle version_status for update notifications