Skip to content

Building a Custom Cmd+Ctrl Daemon ​

This guide walks you through building a daemon that connects any AI agent (or tool) to Cmd+Ctrl. By the end, you'll have a working daemon that lets users interact with your agent through the Cmd+Ctrl web, iOS, and Android apps.

Prerequisites ​

  • A Cmd+Ctrl server (self-hosted or cloud)
  • Node.js 18+ (or any language — the protocol is WebSocket + JSON)
  • Your AI agent, accessible programmatically

What You're Building ​

A daemon is a bridge between Cmd+Ctrl and your agent:

Cmd+Ctrl App  ←→  Cmd+Ctrl Server  ←→  Your Daemon  ←→  Your Agent
   (UI)            (routing)          (bridge)         (LLM/tool)

The daemon:

  1. Maintains a WebSocket connection to the Cmd+Ctrl server
  2. Receives instructions from users
  3. Passes them to your agent
  4. Streams results back

Quick Start: Minimal Daemon ​

Here's a complete daemon in TypeScript. It connects to Cmd+Ctrl, receives tasks, calls an agent function, and returns results.

typescript
// minimal-daemon.ts
import WebSocket from 'ws';
import { randomUUID } from 'crypto';

const SERVER_URL = 'wss://your-server.com/ws/daemon';
const TOKEN = '<your-refresh-token>';
const DEVICE_ID = '<your-device-id>';
const AGENT_TYPE = 'my_agent';  // Choose your agent type name

// --- Your agent integration ---
// Replace this with your actual agent logic
async function runAgent(instruction: string): Promise<string> {
  // Example: call your LLM API, run a CLI tool, etc.
  return `Processed: ${instruction}`;
}

async function resumeAgent(sessionId: string, message: string): Promise<string> {
  return `Follow-up response to: ${message}`;
}

// --- Message storage ---
const sessions = new Map<string, Array<{
  uuid: string; role: string; content: string; timestamp: string;
}>>();

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

// --- Daemon ---
let ws: WebSocket;
let runningTasks: string[] = [];
const watchedSessions = new Set<string>();

function send(msg: object) {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify(msg));
  }
}

function sendStatus() {
  send({ type: 'status', running_tasks: runningTasks });
}

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

  ws.on('open', () => {
    console.log('Connected to Cmd+Ctrl');
    sendStatus();
    send({ type: 'report_sessions', sessions: [] });
  });

  ws.on('message', (data) => handleMessage(JSON.parse(data.toString())));

  ws.on('close', () => {
    console.log('Disconnected, reconnecting in 3s...');
    setTimeout(connect, 3000);
  });

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

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

    case 'task_start': {
      const sessionId = randomUUID();
      const taskId = msg.task_id;
      runningTasks.push(taskId);
      sendStatus();

      // Tell server our native session ID
      send({ type: 'event', task_id: taskId, event_type: 'SESSION_STARTED', session_id: sessionId });

      // Store user message
      storeMessage(sessionId, 'USER', msg.instruction);

      // Run agent
      send({ type: 'event', task_id: taskId, event_type: 'PROGRESS', action: 'Thinking', target: '' });
      try {
        const result = await runAgent(msg.instruction);
        storeMessage(sessionId, 'AGENT', result);
        send({ type: 'event', task_id: taskId, event_type: 'TASK_COMPLETE', result });
      } catch (err: any) {
        send({ type: 'event', task_id: taskId, event_type: 'ERROR', error: err.message });
      }

      runningTasks = runningTasks.filter(t => t !== taskId);
      sendStatus();
      break;
    }

    case 'task_resume': {
      const taskId = msg.task_id;
      runningTasks.push(taskId);
      sendStatus();

      storeMessage(msg.session_id, 'USER', msg.message);

      try {
        const result = await resumeAgent(msg.session_id, msg.message);
        storeMessage(msg.session_id, 'AGENT', result);
        send({ type: 'event', task_id: taskId, event_type: 'TASK_COMPLETE', result });
      } catch (err: any) {
        send({ type: 'event', task_id: taskId, event_type: 'ERROR', error: err.message });
      }

      runningTasks = runningTasks.filter(t => t !== taskId);
      sendStatus();
      break;
    }

    case 'task_cancel':
      runningTasks = runningTasks.filter(t => t !== msg.task_id);
      sendStatus();
      break;

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

    case 'watch_session':
      watchedSessions.add(msg.session_id);
      break;

    case 'unwatch_session':
      watchedSessions.delete(msg.session_id);
      break;
  }
}

connect();

Step-by-Step Walkthrough ​

Step 1: Register Your Device ​

Before your daemon can connect, it needs credentials. The registration flow works like GitHub CLI device authorization:

bash
# Request a verification code
curl -X POST https://your-server/api/devices/code \
  -H 'Content-Type: application/json' \
  -d '{"hostname": "my-workstation", "agent_type": "my_agent"}'

The response includes a verification URL and code. Open the URL in a browser, enter the code, and authenticate. The response includes a device_id and refresh_token.

Store these securely:

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

Tip: See the Daemon Protocol Specification for the full device registration flow.

Step 2: Connect via WebSocket ​

Open a WebSocket to /ws/daemon with auth headers:

typescript
const ws = new WebSocket('wss://your-server/ws/daemon', {
  headers: {
    Authorization: `Bearer ${refreshToken}`,
    'X-Device-ID': deviceId,
    'X-Agent-Type': 'my_agent',
    'X-Daemon-Version': '1.0.0',
  }
});

Immediately send your status. Include any tasks still running after a reconnect in running_tasks, and any known sessions in report_sessions:

typescript
ws.on('open', () => {
  send({ type: 'status', running_tasks: runningTasks });
  send({ type: 'report_sessions', sessions: getSessionList() });
});

Step 3: Handle the Ping/Pong Heartbeat ​

The server pings periodically. Always respond:

typescript
case 'ping':
  send({ type: 'pong' });
  break;

Also send WebSocket-level pings every 30 seconds:

typescript
setInterval(() => ws.ping(), 30000);

Step 4: Handle task_start ​

When a user sends their first message in a new session:

typescript
case 'task_start': {
  const sessionId = randomUUID();
  const taskId = msg.task_id;

  // Track the running task
  runningTasks.push(taskId);
  sendStatus();

  // Tell the server your native session ID (resolves the PENDING placeholder)
  send({
    type: 'event',
    task_id: taskId,
    event_type: 'SESSION_STARTED',
    session_id: sessionId
  });

  // Store the user's message
  storeMessage(sessionId, 'USER', msg.instruction);

  // Run your agent — msg.project_path is an optional working directory hint
  try {
    const result = await myAgent.run(msg.instruction);
    storeMessage(sessionId, 'AGENT', result);
    send({ type: 'event', task_id: taskId, event_type: 'TASK_COMPLETE', result });
  } catch (err: any) {
    send({ type: 'event', task_id: taskId, event_type: 'ERROR', error: err.message });
  }

  // Remove from running tasks
  runningTasks = runningTasks.filter(t => t !== taskId);
  sendStatus();
  break;
}

Step 5: Handle task_resume ​

Follow-up messages in an existing session. The session_id is your agent's native session ID, and project_path is the optional working directory hint.

typescript
case 'task_resume': {
  const taskId = msg.task_id;
  runningTasks.push(taskId);
  sendStatus();

  storeMessage(msg.session_id, 'USER', msg.message);

  try {
    const result = await myAgent.resume(msg.session_id, msg.message);
    storeMessage(msg.session_id, 'AGENT', result);
    send({ type: 'event', task_id: taskId, event_type: 'TASK_COMPLETE', result });
  } catch (err: any) {
    send({ type: 'event', task_id: taskId, event_type: 'ERROR', error: err.message });
  }

  runningTasks = runningTasks.filter(t => t !== taskId);
  sendStatus();
  break;
}

Step 6: Serve Message History ​

Cmd+Ctrl clients fetch messages through your daemon. Implement get_messages:

typescript
case 'get_messages': {
  let messages = sessions.get(msg.session_id) || [];

  // Handle pagination cursors
  if (msg.before_uuid) {
    const idx = messages.findIndex(m => m.uuid === msg.before_uuid);
    if (idx > 0) messages = messages.slice(0, idx);
  }
  if (msg.after_uuid) {
    const idx = messages.findIndex(m => m.uuid === msg.after_uuid);
    if (idx >= 0) messages = messages.slice(idx + 1);
  }

  const limited = messages.slice(-msg.limit);

  send({
    type: 'messages',
    request_id: msg.request_id,
    session_id: msg.session_id,
    messages: limited,
    has_more: limited.length < messages.length,
    oldest_uuid: limited[0]?.uuid,
    newest_uuid: limited[limited.length - 1]?.uuid,
  });
  break;
}

Step 7: Track Running Tasks ​

Keep the server informed about what's running:

typescript
let runningTasks: string[] = [];

// When starting a task:
runningTasks.push(taskId);
send({ type: 'status', running_tasks: runningTasks });

// When a task completes:
runningTasks = runningTasks.filter(t => t !== taskId);
send({ type: 'status', running_tasks: runningTasks });

Step 8: Reconnection ​

Always reconnect on disconnect:

typescript
let reconnectDelay = 1000;

ws.on('close', () => {
  setTimeout(() => {
    reconnectDelay = Math.min(reconnectDelay * 2, 30000);
    connect();
  }, reconnectDelay);
});

ws.on('open', () => {
  reconnectDelay = 1000; // Reset on success
});

Step 9: Session Watching ​

When a user opens a session in the UI, the server sends watch_session. Monitor the session for changes and push session_activity updates:

typescript
const watchedSessions = new Set<string>();

case 'watch_session':
  watchedSessions.add(msg.session_id);
  break;

case 'unwatch_session':
  watchedSessions.delete(msg.session_id);
  break;

Whenever a watched session changes (new messages, completion, etc.), send an activity update:

typescript
function notifyActivity(sessionId: string, lastMessage: string, isCompletion: boolean) {
  if (!watchedSessions.has(sessionId)) return;
  send({
    type: 'session_activity',
    session_id: sessionId,
    last_message: lastMessage,
    message_count: sessions.get(sessionId)?.length || 0,
    is_completion: isCompletion,
    last_activity: new Date().toISOString(),
  });
}

The is_completion field matters — when true, it triggers push notifications on mobile.

Step 10: Periodic Session Reporting ​

The initial report_sessions on connect reports your daemon's known sessions. You should also send periodic updates so the server discovers sessions started outside of Cmd+Ctrl (e.g., from the CLI):

typescript
setInterval(() => {
  const sessionList = Array.from(sessions.entries()).map(([id, msgs]) => ({
    session_id: id,
    last_message: msgs[msgs.length - 1]?.content || '',
    last_activity: msgs[msgs.length - 1]?.timestamp || new Date().toISOString(),
    is_active: runningTasks.some(t => t.includes(id)),
    message_count: msgs.length,
  }));
  send({ type: 'report_sessions', sessions: sessionList });
}, 30000);

Example: Integrating an LLM ​

Here's how you might integrate an LLM with a chat API:

typescript
import { MyLLMClient } from 'my-llm-sdk';

const llm = new MyLLMClient({ apiKey: process.env.LLM_API_KEY });

// Track conversation history per session
const conversations = new Map<string, Array<{role: string, content: string}>>();

async function runAgent(instruction: string): Promise<{sessionId: string, result: string}> {
  const sessionId = randomUUID();
  const history = [{ role: 'user', content: instruction }];
  conversations.set(sessionId, history);

  const response = await llm.chat({ messages: history });

  history.push({ role: 'assistant', content: response.content });
  return { sessionId, result: response.content };
}

async function resumeAgent(sessionId: string, message: string): Promise<string> {
  const history = conversations.get(sessionId) || [];
  history.push({ role: 'user', content: message });

  const response = await llm.chat({ messages: history });

  history.push({ role: 'assistant', content: response.content });
  return response.content;
}

Streaming Progress ​

For long-running agents, send progress events so users see live updates:

typescript
// Simple status indicator
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 file: src/main.py\nFound 3 potential issues...\n'
});

Asking the User Questions ​

If your agent needs user input mid-task, use WAIT_FOR_USER instead of TASK_COMPLETE:

typescript
send({
  type: 'event',
  task_id: taskId,
  event_type: 'WAIT_FOR_USER',
  prompt: 'Which approach should I use?',
  result: 'I found two ways to solve this:\n\n1. Refactor the auth module\n2. Add a middleware layer\n\nWhich approach should I use?',
  options: [
    { label: 'Refactor auth module' },
    { label: 'Add middleware layer' }
  ]
});

The user's response arrives as a task_resume message.

Error Handling ​

Always send an ERROR event if something goes wrong — otherwise the session gets stuck:

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

Testing Your Daemon ​

  1. Start the Cmd+Ctrl dev server: ./dev-start.sh
  2. Register your daemon: Run your registration flow against http://localhost:4000
  3. Start your daemon: Connect to ws://localhost:4000/ws/daemon
  4. Open the Cmd+Ctrl web UI: http://localhost:5173
  5. Create a session: Select your device from the host dropdown
  6. Send a message: Your daemon should receive a task_start

Debugging Tips ​

  • Log all incoming/outgoing WebSocket messages during development
  • Check /tmp/cmdctrl-api.log for server-side errors
  • The server logs daemon connections and disconnections
  • If your device doesn't appear in the UI, check that it's connected (the server updates devices.status = 'online' on connection)

Using the @cmdctrl/daemon-sdk (TypeScript) ​

For TypeScript/Node.js daemons, the SDK handles the WebSocket boilerplate:

typescript
import { DaemonClient } from '@cmdctrl/daemon-sdk';

const client = new DaemonClient({
  serverUrl: 'https://your-server.com',
  deviceId: 'device-123',
  agentType: 'my_agent',
  token: refreshToken,
  version: '1.0.0',
});

client.onTaskStart(async (task) => {
  const sessionId = randomUUID();
  task.sessionStarted(sessionId);

  task.progress('Thinking', '');
  const result = await myAgent.run(task.instruction);

  task.complete(result);
  return sessionId;
});

client.onTaskResume(async (task) => {
  const result = await myAgent.resume(task.sessionId, task.message);
  task.complete(result);
});

client.onGetMessages((request) => {
  return myMessageStore.getMessages(request.sessionId, request.limit);
});

await client.connect();

Note: The @cmdctrl/daemon-sdk package provides the DaemonClient class, message type definitions, and utilities for registration and config management. See the package README for full API documentation.

Writing Daemons in Other Languages ​

The protocol is language-agnostic — any WebSocket client that sends/receives JSON works. Key considerations:

  • Python: Use websockets library. JSON message format is identical.
  • Go: Use gorilla/websocket. Marshal/unmarshal with encoding/json.
  • Rust: Use tokio-tungstenite. Serde for JSON.

The only requirement is the message format described in the Daemon Protocol Specification.

Reference ​