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