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:
- Maintains a WebSocket connection to the Cmd+Ctrl server
- Receives instructions from users
- Passes them to your agent
- 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.
// 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:
# 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:
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:
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:
case 'ping':
send({ type: 'pong' });
break;Also send WebSocket-level pings every 30 seconds:
setInterval(() => ws.ping(), 30000);Step 4: Handle task_start
When a user sends their first message in a new session:
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.
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:
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:
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:
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:
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:
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):
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:
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:
// 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:
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:
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
- Start the Cmd+Ctrl dev server:
./dev-start.sh - Register your daemon: Run your registration flow against
http://localhost:4000 - Start your daemon: Connect to
ws://localhost:4000/ws/daemon - Open the Cmd+Ctrl web UI:
http://localhost:5173 - Create a session: Select your device from the host dropdown
- Send a message: Your daemon should receive a
task_start
Debugging Tips
- Log all incoming/outgoing WebSocket messages during development
- Check
/tmp/cmdctrl-api.logfor 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:
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-sdkpackage provides theDaemonClientclass, 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
websocketslibrary. JSON message format is identical. - Go: Use
gorilla/websocket. Marshal/unmarshal withencoding/json. - Rust: Use
tokio-tungstenite. Serde for JSON.
The only requirement is the message format described in the Daemon Protocol Specification.
Reference
- Daemon Protocol Specification — complete WebSocket protocol reference