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 AgentBuild 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"
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.
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:
| Message | When | Your Response |
|---|---|---|
ping | Every ~30s | Send pong |
task_start | New conversation | Run agent, send TASK_COMPLETE |
task_resume | Follow-up message | Continue conversation, send TASK_COMPLETE |
task_cancel | User cancelled | Stop the agent, update status |
force_quit_session | User force-quits a session | Stop it, reply force_quit_result with the request_id |
watch_session / unwatch_session | User opens or leaves a reported session | Start or stop sending session_activity |
get_messages | Client loads history | Return stored messages |
task_start Lifecycle
- Generate a session ID (UUID)
- Send
SESSION_STARTEDevent with that ID – this resolves the pending session in the UI - Store the user's message
- Run your agent
- Store + send the result as
TASK_COMPLETE - 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:
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:
// 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:
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:
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:
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):
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:
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.