Skip to content

Cmd+Ctrl Is a Mobile App for Any Agent ​

Build Your Own Cmd+Ctrl Daemon

Yo Cmd+Ctrl, Where's the Support for My Coding Agent? ​

As you may know, Cmd+Ctrl lets you manage all of your coding agents from your phone or the web.

But did you also know -- if you use an agent that isn't supported, you can just add support for it yourself? Don't see Hermes agent listed? You can build it!

(Side note: I haven't gotten around to publishing the official Cmd+Ctrl Hermes agent daemon yet. If you want me to support it, let me know and I'll prioritize completion.)

The daemon model is very simple:

Cmd+Ctrl App  ←→  Cmd+Ctrl Server  ←→  Your Daemon  ←→  Your Agent

Build one yourself by just telling your agent to read the prompt in this blog post and it'll write the daemon for you. Then you'll be able to talk to your agent on mobile using the Cmd+Ctrl app.

The Nitty Gritty ​

Cmd+Ctrl already supports the major coding agents: Claude Code, Codex, Cursor, GitHub Copilot, OpenCode, OpenClaw, Pi, oh-my-pi and Aider. The daemon code for those agents is all open source and lives at cmdctrlai/daemons.

If the agent you use isn't on that list, have your coding agent write the daemon for it against the published SDK.

And, by the way, it doesn't have to be specifically a coding agent. Any AI agent – a research assistant, a support bot, something you built yourself – can get a sidecar daemon and show up in the app.

A daemon is a WebSocket bridge between your agent and Cmd+Ctrl. It receives instructions from users, passes them to your agent, and streams results back. Any language works – the protocol is JSON over WebSocket.

"Just give me the prompt to have my AI build it"
markdown
Build a Cmd+Ctrl daemon for [YOUR AGENT NAME HERE]: a process that connects to Cmd+Ctrl over WebSocket and bridges user messages to the agent.

Use `@cmdctrl/daemon-sdk` (`npm install @cmdctrl/daemon-sdk`); read its README and types before writing code. Protocol background: https://docs.cmd-ctrl.ai/daemon-protocol. Where that page and the SDK's types disagree, trust the types.

Requirements:
1. Register with `registerDevice()` and persist the result with `ConfigManager`. Treat `invalid_device_code` and `expired_token` as terminal – ask the user to start registration again.
2. `onTaskStart`: start an agent session, call `task.sessionStarted(<native id>)` as soon as you have the ID, stream with `task.progress()` / `task.output()`, finish with `task.complete(result)`. Its task ID is normally `<device>:<agent>:PENDING-…`; if it is a real session ID instead (a session that has no messages yet), continue that session rather than creating one.
3. `onTaskResume`: continue the native session with the user's new message.
4. When the agent asks the user something, call `task.waitForUser(prompt, result, options?)`.
5. Store every message for every session: the user's instruction, the agent's final reply, and any `waitForUser` question (as an AGENT message).
6. `onGetMessages`: return stored messages for the session, honouring `limit`, `beforeUuid` and `afterUuid`, with `hasMore`, `oldestUuid` and `newestUuid` set.
7. `setSessionsProvider`: report sessions that already exist, each with a `file_path` your daemon can use to find it again. On `onWatchSession`, call `client.sendSessionActivity()` whenever that session changes, until `onUnwatchSession`.
8. Cancellation arrives as `onTaskCancel` or `onForceQuitSession`; stop the agent for both.

Write it, run it against the server, start a session from the Cmd+Ctrl app, and confirm the reply and the message history both show up.

The Gory Details ​

The Minimal Daemon ​

This is a complete, working daemon. Read it top to bottom, then we'll break down the interesting parts.

typescript
import WebSocket from 'ws';
import { randomUUID } from 'crypto';

const SERVER = 'wss://api.cmd-ctrl.ai/ws/daemon';
const TOKEN = '<your-refresh-token>';
const DEVICE_ID = '<your-device-id>';

// Replace with your agent logic
async function runAgent(instruction: string): Promise<string> {
  return `Processed: ${instruction}`;
}

// In-memory message store
const sessions = new Map<string, Array<{
  uuid: string; role: string; content: string; timestamp: string;
}>>();

function store(sessionId: string, role: string, content: string) {
  if (!sessions.has(sessionId)) sessions.set(sessionId, []);
  sessions.get(sessionId)!.push({
    uuid: randomUUID(), role, content,
    timestamp: new Date().toISOString(),
  });
}

let ws: WebSocket;
let running: string[] = [];
let retryDelay = 1000;

function send(msg: object) {
  ws.send(JSON.stringify(msg));
}

function connect() {
  ws = new WebSocket(SERVER, {
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      'X-Device-ID': DEVICE_ID,
      'X-Daemon-Version': '1.0.0',
    }
  });

  ws.on('open', () => {
    retryDelay = 1000;
    send({ type: 'status', running_tasks: running });
    send({ type: 'report_sessions', sessions: [] });
  });

  ws.on('message', (data) => handle(JSON.parse(data.toString())));
  ws.on('error', (err) => {
    console.error(err.message);
    // 410: this device was unregistered. Register again instead of retrying.
    if (err.message.endsWith(' 410')) process.exit(1);
  });
  ws.on('close', () => {
    setTimeout(connect, retryDelay);
    retryDelay = Math.min(retryDelay * 2, 30000);
  });
}

setInterval(() => { if (ws.readyState === WebSocket.OPEN) ws.ping(); }, 30000);

async function handle(msg: any) {
  switch (msg.type) {
    case 'ping':
      send({ type: 'pong' });
      break;

    case 'task_start': {
      const sid = randomUUID();
      running.push(msg.task_id);
      send({ type: 'status', running_tasks: running });
      send({ type: 'event', task_id: msg.task_id,
             event_type: 'SESSION_STARTED', session_id: sid });

      store(sid, 'USER', msg.instruction);
      try {
        const result = await runAgent(msg.instruction);
        store(sid, 'AGENT', result);
        send({ type: 'event', task_id: msg.task_id,
               event_type: 'TASK_COMPLETE', result });
      } catch (err: any) {
        send({ type: 'event', task_id: msg.task_id,
               event_type: 'ERROR', error: err.message });
      }
      running = running.filter(t => t !== msg.task_id);
      send({ type: 'status', running_tasks: running });
      break;
    }

    case 'task_resume': {
      running.push(msg.task_id);
      send({ type: 'status', running_tasks: running });
      store(msg.session_id, 'USER', msg.message);
      try {
        const result = await runAgent(msg.message);
        store(msg.session_id, 'AGENT', result);
        send({ type: 'event', task_id: msg.task_id,
               event_type: 'TASK_COMPLETE', result });
      } catch (err: any) {
        send({ type: 'event', task_id: msg.task_id,
               event_type: 'ERROR', error: err.message });
      }
      running = running.filter(t => t !== msg.task_id);
      send({ type: 'status', running_tasks: running });
      break;
    }

    case 'task_cancel':
      // Stop your agent here.
      running = running.filter(t => t !== msg.task_id);
      send({ type: 'status', running_tasks: running });
      break;

    case 'get_messages': {
      const msgs = sessions.get(msg.session_id) || [];
      send({
        type: 'messages', request_id: msg.request_id,
        session_id: msg.session_id, messages: msgs,
        has_more: false,
        oldest_uuid: msgs[0]?.uuid,
        newest_uuid: msgs[msgs.length - 1]?.uuid,
      });
      break;
    }
  }
}

connect();

Save it as daemon.ts, fill in the token and device ID from Device Registration, then npm install ws tsx and npx tsx daemon.ts. Swap out runAgent() with your actual agent integration and you have a working daemon.

How It Works ​

Connection ​

Your daemon opens a WebSocket to /ws/daemon with auth headers. On connect, send two messages:

  • status – tells the server what tasks you're running (include any still running after a reconnect)
  • report_sessions – tells the server about any existing sessions (send periodically too, e.g. every 30s)

The minimal daemon reports an empty list. To show sessions that already exist, report each with a file_path your daemon can use to find it again. When a user opens one, the server sends watch_session with that path; send session_activity each time the session changes, until unwatch_session.

If the connection drops, reconnect with backoff. A 410 on reconnect means the device was unregistered, so retrying is pointless – register again. (The SDK, as of 1.6.6, keeps retrying on a 410; a fix is coming.)

Message Flow ​

The server sends you these message types:

MessageWhenYour Response
pingEvery ~30sSend pong
task_startNew conversationRun agent, send TASK_COMPLETE
task_resumeFollow-up messageContinue conversation, send TASK_COMPLETE
task_cancelUser cancelledStop the agent, update status
force_quit_sessionUser force-quits a sessionStop it, reply force_quit_result with the request_id
watch_session / unwatch_sessionUser opens or leaves a reported sessionStart or stop sending session_activity
get_messagesClient loads historyReturn stored messages

task_start Lifecycle ​

  1. Generate a session ID (UUID)
  2. Send SESSION_STARTED event with that ID – this resolves the pending session in the UI
  3. Store the user's message
  4. Run your agent
  5. Store + send the result as TASK_COMPLETE
  6. Update running task status

The task_id is normally <device>:<agent>:PENDING-…. If it is a real session ID instead – a session that has no messages yet – continue that session rather than creating a new one.

task_resume ​

Same as task_start, but the session already exists. You get msg.session_id and msg.message instead of msg.instruction.

get_messages ​

Clients fetch message history through your daemon. Reply with a messages message whose messages array holds {uuid, role, content, timestamp} objects. The request_id must be echoed back so the server can route the response.

For pagination, the server sends before_uuid, after_uuid, and limit. Handle these if you have long conversations:

typescript
case 'get_messages': {
  let msgs = sessions.get(msg.session_id) || [];
  if (msg.after_uuid) {
    const idx = msgs.findIndex(m => m.uuid === msg.after_uuid);
    if (idx >= 0) msgs = msgs.slice(idx + 1);
  }
  if (msg.before_uuid) {
    const idx = msgs.findIndex(m => m.uuid === msg.before_uuid);
    if (idx >= 0) msgs = msgs.slice(0, idx);
  }
  const limited = msgs.slice(-msg.limit);
  send({
    type: 'messages', request_id: msg.request_id,
    session_id: msg.session_id, messages: limited,
    has_more: limited.length < msgs.length,
    oldest_uuid: limited[0]?.uuid,
    newest_uuid: limited[limited.length - 1]?.uuid,
  });
  break;
}

Streaming Progress ​

Long-running agents should send progress events so users see live updates:

typescript
// Status line (shown in session header)
send({ type: 'event', task_id: taskId,
       event_type: 'PROGRESS', action: 'Analyzing', target: 'codebase' });

// Verbose output (shown in expanded view)
send({ type: 'event', task_id: taskId,
       event_type: 'OUTPUT', output: 'Reading src/main.py...\n' });

Asking the User ​

If your agent needs input mid-task, store the question as an AGENT message, then send WAIT_FOR_USER instead of TASK_COMPLETE. Chat content only ever comes from your get_messages replies – the event itself just sets the waiting status and the push text:

typescript
store(sessionId, 'AGENT', 'Which approach? 1. Refactor auth  2. Add middleware');
send({ type: 'event', task_id: taskId, event_type: 'WAIT_FOR_USER', prompt: 'Which approach?' });

The user's response arrives as a task_resume.

Error Handling ​

Always send ERROR if something goes wrong – otherwise the session hangs:

typescript
try {
  const result = await runAgent(instruction);
  send({ type: 'event', task_id, event_type: 'TASK_COMPLETE', result });
} catch (err) {
  send({ type: 'event', task_id, event_type: 'ERROR',
         error: `Agent failed: ${(err as Error).message}` });
}

Device Registration ​

Before connecting, your daemon needs a device ID and refresh token. Use the device code flow:

bash
curl -X POST https://api.cmd-ctrl.ai/api/devices/code \
  -H 'Content-Type: application/json' \
  -d '{"hostname": "my-workstation", "agentType": "my_agent"}'

This returns a deviceCode and a verificationUrl. Open the URL, sign in, and confirm the device. Then exchange the device code (it expires after 15 minutes):

bash
curl -X POST https://api.cmd-ctrl.ai/api/devices/token \
  -H 'Content-Type: application/json' \
  -d '{"deviceCode": "<deviceCode>"}'

Before you confirm, this returns {"error":"authorization_pending"}; a daemon polls it every interval seconds. After, you get back a deviceId and refreshToken. Store them:

~/.cmdctrl-my-agent/
├── config.json       # {"serverUrl": "...", "deviceId": "...", "deviceName": "..."}
└── credentials       # {"refreshToken": "..."} (chmod 600)

Using the SDK ​

For TypeScript daemons, the @cmdctrl/daemon-sdk handles the WebSocket boilerplate:

typescript
import { randomUUID } from 'crypto';
import { DaemonClient, type MessageEntry } from '@cmdctrl/daemon-sdk';

const sessions = new Map<string, MessageEntry[]>();
function store(sessionId: string, role: 'USER' | 'AGENT', content: string) {
  if (!sessions.has(sessionId)) sessions.set(sessionId, []);
  sessions.get(sessionId)!.push({
    uuid: randomUUID(), role, content, timestamp: new Date().toISOString(),
  });
}

const client = new DaemonClient({
  serverUrl: 'https://api.cmd-ctrl.ai',
  deviceId: '<your-device-id>',
  agentType: 'my_agent',
  token: '<your-refresh-token>',
  version: '1.0.0',
});

client.onTaskStart(async (task) => {
  const sessionId = randomUUID();
  task.sessionStarted(sessionId);
  store(sessionId, 'USER', task.instruction);
  task.progress('Thinking', '');
  const result = await runAgent(task.instruction);
  store(sessionId, 'AGENT', result);
  task.complete(result);
});

client.onTaskResume(async (task) => {
  store(task.sessionId, 'USER', task.message);
  const result = await runAgent(task.message);
  store(task.sessionId, 'AGENT', result);
  task.complete(result);
});

client.onGetMessages((req) => {
  let all = sessions.get(req.sessionId) || [];
  if (req.afterUuid) {
    const idx = all.findIndex(m => m.uuid === req.afterUuid);
    if (idx >= 0) all = all.slice(idx + 1);
  }
  if (req.beforeUuid) {
    const idx = all.findIndex(m => m.uuid === req.beforeUuid);
    if (idx >= 0) all = all.slice(0, idx);
  }
  const messages = all.slice(-req.limit);
  return {
    messages, hasMore: messages.length < all.length,
    oldestUuid: messages[0]?.uuid,
    newestUuid: messages[messages.length - 1]?.uuid,
  };
});

// Rejects if the first attempt fails; the SDK keeps retrying either way.
client.connect().catch((err) => console.error(err.message));

runAgent() is the same as in the minimal daemon. The SDK also exports registerDevice(), which runs the registration flow above, and ConfigManager, which stores the result.

Any Language Works ​

The protocol is just JSON over WebSocket. Python (websockets), Go (gorilla/websocket), Rust (tokio-tungstenite) – anything that can open a WebSocket and send JSON will work.

See the Daemon Protocol Specification for the complete message reference.

Are You Human? ​

If you're a human and you've read this far, good on ya! Tell me if you add a daemon. If it's widely used, I'll add it to the official support list.


Reach me on X or LinkedIn.