Daemon Network Intelligence System
Overview
A discovery tool and an extended subagent system that give agents full awareness of the daemon fleet and the ability to delegate work across it:
daemon_network— Search, list, and profile daemons in the user's network. Uses Haiku for semantic matching against discover profiles (apps, logins, CDP, projects, skills).tasktool extended withtarget_daemon— The existing local subagent tool gains an optionaltarget_daemonparameter. Without it, behaves exactly as today (local subagent). With it, executes the task on a remote daemon via Hub WebSocket — with full streaming, permission proxying, and question forwarding — and returns the result.
Combined with system prompt integration, these enable intelligent work distribution: the agent on a daily driver automatically reasons about which 24/7 daemon should handle browser automation, desktop automation, and long-running tasks — keeping the user's primary machine undisturbed.
Critical constraint: Skill-building mode, plan mode, and interactive/conversational tasks always stay on the current machine. These are inherently collaborative between the user and the agent and must never be delegated. This is enforced both in the system prompt (soft) and in the task tool itself (hard guard).
Problem
Agents currently operate in isolation. They have no visibility into what other daemons exist, what capabilities they have (installed apps, browser accounts, CDP access), or how to delegate work to them. This creates two problems:
- Daily driver disruption — dom0/cmd0 tasks run on the user's machine, hijacking their browser, moving windows, running scripts while they work.
- Underutilized 24/7 agents — Dedicated agent daemons sit idle while the daily driver does everything, even tasks the user never needs to see.
Solution: Give agents a "daemon directory" with semantic search, and extend the existing task tool so any daemon in the network becomes an available worker — no new tool to learn, just an extra parameter.
Tool 1: daemon_network
Purpose
The agent's directory of all daemons in the user's fleet. Provides listing, semantic search (via Haiku), and detailed profiles. This is the foundation for both remote task delegation and schedule cron targeting.
Actions
| Action | Purpose | Required Params |
|---|---|---|
list | List all daemons with summary profiles | — |
search | Semantic search using Haiku matching | query |
profile | Get full discover profile of a specific daemon | daemon_id or daemon_name |
Input Schema
action: enum [list, search, profile]
query: string (required for search) — natural language, e.g., "daemon with LinkedIn access"
daemon_id: string (required for profile by ID)
daemon_name: string (alternative for profile by name)
role_filter: enum [all, agent, daily-driver] (optional, default 'all')
online_only: boolean (optional, default false)
Data Available Per Daemon
The tool aggregates data from ctx0_daemons (including discovery_data) and returns:
| Field | Source | Description |
|---|---|---|
id | ctx0_daemons.id | Daemon UUID |
name | ctx0_daemons.name | Human name (e.g., "atlas") |
deviceId | ctx0_daemons.device_id | Device identifier |
role | ctx0_daemons.device_role | 'daily-driver' or 'agent' (24/7) |
description | ctx0_daemons.description | NEW — Purpose description |
category | ctx0_daemons.category | NEW — Org category (engineering, marketing, etc.) |
online | hub_connection_id IS NOT NULL | Live connection status |
lastSeenAt | ctx0_daemons.last_seen_at | Last activity timestamp |
| Discover Profile | ctx0_daemons.discovery_data | — |
apps | discovery_data.apps | Installed + running apps with frameworks, dom0/cmd0 routing |
savedLogins | discovery_data.savedLogins | Chrome saved login domains (linkedin.com, github.com, etc.) |
cdpConnections | discovery_data.cdp | Active Chrome DevTools Protocol connections |
gitProjects | discovery_data.projects | Git repos with paths and last activity |
browserTabs | discovery_data.tabs | Open browser tabs |
platform | discovery_data.platform | OS info (macOS, Linux, Windows) |
| Skills | ctx0_daemons.skills | NEW — Synced skill names + descriptions |
| Tools | ctx0_daemons.tools | Loaded tool names |
Search Flow (Haiku Matching)
Agent calls daemon_network({ action: "search", query: "daemon with LinkedIn access and dom0" })
│
▼
Tool fetches all daemon profiles from proxy
POST /api/proxy/daemons/list (device-authenticated)
│
▼
Tool constructs compact JSON payload of all daemon profiles
Strips verbose fields, keeps: name, role, online, apps, savedLogins, cdp, projects, skills, description
│
▼
Tool calls Haiku via proxy
POST /api/proxy/llm/anthropic/v1/messages
Model: claude-haiku-4-5-20251001
System: "You are a daemon matcher. Given daemon profiles, find the best matches for the query.
Return JSON: { matches: [{ id, name, reasoning }], noMatch?: string }"
User: "<profiles>{json}</profiles>\n\nQuery: {query}"
│
▼
Tool parses Haiku response and returns formatted result to main agent
Why Haiku? The main agent (Sonnet/Opus) shouldn't waste expensive tokens parsing N daemon profiles. Haiku is 10-20x cheaper and fast enough for structured matching. The main agent gets a concise answer.
list Output Format
Daemon Network (4 daemons)
24/7 Agents:
● atlas [online] — macOS · Engineering
"Primary CI/CD and code review agent"
Apps: 12 installed · Logins: github.com, linear.com, slack.com
CDP: available · Projects: 15 · Skills: 3
Device: abc123...
○ sentinel [offline] — Linux · Infrastructure
"Server monitoring and deployment agent"
Apps: 5 installed · Logins: aws.amazon.com, grafana.example.com
CDP: unavailable · Projects: 8 · Skills: 1
Device: def456...
Daily Drivers:
● jarvis [online] — macOS · Personal
"Andre's daily driver"
Apps: 45 installed · Logins: linkedin.com, gmail.com, notion.so
CDP: available · Projects: 30 · Skills: 5
Device: ghi789...
○ workbox [offline] — Windows · Design
"Design workstation"
Device: jkl012...
search Output Format
Search: "daemon that can check LinkedIn"
Matches:
1. jarvis [daily-driver, online]
✓ Has linkedin.com in saved logins
✓ CDP available for browser automation
⚠ Daily driver — will disrupt user's browser
2. atlas [agent, online]
✗ No LinkedIn login found
✓ CDP available
ℹ Could authenticate if needed
Recommendation: jarvis has LinkedIn access but is a daily driver.
Consider having atlas authenticate to LinkedIn first for unattended automation.
profile Output Format
Full discover profile (same format as the discover tool output) plus daemon metadata.
Dependencies
interface DaemonNetworkToolDeps { getConfig: () => DaemonConfig; getSigningProvider: () => SigningProvider | null; }
The tool calls proxy endpoints directly using fetch + device signing headers (same pattern as Exa tools, schedule tool). For Haiku search, it calls the LLM proxy endpoint.
Root Agent Only
Add 'daemon_network' to deniedTools for all subagent types in agents.ts. Network-level decisions should only be made by the root agent.
Exception: Consider allowing read-only list action for subagents in the future if needed for context-gathering.
Extended task Tool — Remote Execution via target_daemon
Design Decision: Extend, Don't Duplicate
Rather than creating a separate remote_task tool, we extend the existing task tool with an optional target_daemon parameter. This keeps the agent's mental model simple — one tool for subagent delegation, local or remote.
task({ agent: "general", prompt: "..." }) → local subagent (current behavior)
task({ prompt: "...", target_daemon: "abc123" }) → remote execution via Hub
When target_daemon is set, the agent parameter is ignored — the remote daemon runs its own root agent (which spawns its own subagents as needed). The source daemon doesn't know the remote daemon's tool registry, so specifying a subagent type doesn't apply remotely.
Updated Input Schema
Add to the existing task tool input schema:
agent: enum [explore, general, browser, plan] (existing — ignored when target_daemon is set)
prompt: string (existing)
target_daemon: string (NEW, optional — device ID of target daemon)
timeout: number (NEW, optional — max wait in seconds for remote tasks, default 300, max 600)
When target_daemon is omitted, the tool behaves exactly as it does today — spawns a local child Bot0Agent in-process.
When target_daemon is provided, the tool takes the remote code path with full streaming, permission proxying, and question forwarding — the user sees live progress on their desktop exactly as if the agent were running locally.
Hard Guard: Never-Delegate Modes
The task tool rejects target_daemon when the agent is in skill_builder or plan mode — regardless of what the system prompt says. This is a belt-and-suspenders safeguard:
// In task tool execute(), before any remote logic: if (input.target_daemon) { const ctx = getTaskContext(); const agentMode = ctx?.agentMode; if (agentMode === 'skill_builder' || agentMode === 'plan') { return 'Cannot delegate to remote daemons in skill_builder or plan mode. These modes require local execution for interactive collaboration with the user.'; } }
System prompt guidelines handle the soft cases (conversational tasks, local-file tasks). The hard guard handles the critical cases where delegation would break the user experience.
Remote Execution Flow — Full Streaming + Permissions + Questions
The remote execution flow mirrors the existing desktop-daemon pattern (see hub/client.ts:369-665). The target daemon streams all progress, permissions, and questions back to the source daemon, which proxies them through to its desktop UI. The user sees live tool calls, can approve/deny permissions, and answer questions — just as if the agent were running locally.
User Desktop Source Daemon (daily driver) Hub Target Daemon (24/7)
│ │ │ │
│ (user sends message) │ │ │
│ ─────────────────────▶ │ │ │
│ │ hub:daemon_task_submit │ │
│ │ ──────────────────────────▶ │ │
│ │ │ ─────────────▶ │
│ │ │ │
│ │ │ Create session
│ │ │ (type: 'remote_task')
│ │ │ │
│ │ │ Run Bot0Agent
│ │ hub:daemon_task_progress │ │
│ │ (thinking, tool_call, etc) │ │
│ TaskToolMetadata │ ◀──────────────────────────│ ◀───────────── │
│ (live tool display) │ │ │
│ ◀───────────────────── │ │ │
│ │ │ │
│ │ hub:daemon_task_progress │ │
│ │ (permission_request) │ │
│ Permission Prompt │ ◀──────────────────────────│ ◀───────────── │
│ ◀───────────────────── │ │ │
│ │ │ │
│ User: "Allow" │ │ │
│ ─────────────────────▶ │ │ │
│ │ hub:permission_reply │ │
│ │ ──────────────────────────▶ │ ─────────────▶ │
│ │ │ │
│ │ │ (continues executing)
│ │ │ │
│ │ hub:daemon_task_result │ │
│ <task_result> XML │ ◀──────────────────────────│ ◀───────────── │
│ ◀───────────────────── │ │ │
Hub Protocol — New and Reused Message Types
3 new daemon-to-daemon types added to HubMessageType:
| 'hub:daemon_task_submit' | 'hub:daemon_task_progress' | 'hub:daemon_task_result'
2 existing types reused for daemon-to-daemon permission/question replies:
hub:permission_reply— already hastargetDaemonDeviceIdand the correct reply shape. Currently sent by desktops; now also sent by daemons. The Hub routes to the target daemon regardless of sender type.hub:question_reply— same pattern. Already routes totargetDaemonDeviceId.
This avoids duplicating message types. The Hub is dumb — it forwards to targetDaemonDeviceId without caring whether the sender is a desktop or daemon.
New message definitions:
// ─── Daemon → Daemon: Task Submission ──────────────────────────────────────── interface HubDaemonTaskSubmitMessage extends HubMessageBase { type: 'hub:daemon_task_submit'; targetDaemonDeviceId: string; // Which daemon to execute on sourceDaemonDeviceId: string; // Where to send results/progress back prompt: string; context?: string; workingDirectory?: string; } // ─── Daemon → Daemon: Streaming Progress ───────────────────────────────────── // Mirrors hub:task_progress (desktop↔daemon) but routes daemon-to-daemon. // Carries the same fields so the source daemon can proxy to its desktop UI. interface HubDaemonTaskProgressMessage extends HubMessageBase { type: 'hub:daemon_task_progress'; targetDaemonDeviceId: string; // Source daemon (where to route progress) sourceDaemonRequestId: string; // Original requestId from submit (for correlation) taskId: string; progressType: 'thinking' | 'assistant' | 'tool_call' | 'tool_result' | 'tool_metadata' | 'error' | 'done' | 'permission_request' | 'question_request'; content?: string; tool?: string; input?: unknown; result?: string; error?: string; tokenCount?: number; inputTokens?: number; outputTokens?: number; contextTokens?: number; metadata?: Record<string, unknown>; // Permission request fields (when progressType === 'permission_request') permissionRequestId?: string; permission?: string; permissionPatterns?: string[]; // Question request fields (when progressType === 'question_request') questionRequestId?: string; questions?: Array<{ question: string; header: string; options: Array<{ label: string; description: string }>; multiple?: boolean; }>; } // ─── Daemon → Daemon: Final Result ─────────────────────────────────────────── interface HubDaemonTaskResultMessage extends HubMessageBase { type: 'hub:daemon_task_result'; targetDaemonDeviceId: string; // Source daemon (where to route result) sourceDaemonRequestId: string; // Original requestId from submit (for correlation) taskId: string; success: boolean; text?: string; error?: string; iterations?: number; inputTokens?: number; outputTokens?: number; durationMs?: number; }
Hub Routing — Daemon-to-Daemon
Current state: The Hub only routes Desktop <-> Daemon. No daemon-to-daemon.
Change: Add daemon-to-daemon routing for the 3 new message types. The existing hub:permission_reply and hub:question_reply already route to targetDaemonDeviceId — they just need to accept daemon senders too (not only desktop senders).
// In Hub.handleMessage() — add to daemon client section: // New daemon-to-daemon types case 'hub:daemon_task_submit': case 'hub:daemon_task_progress': case 'hub:daemon_task_result': this.routeDaemonToDaemon(client, msg); break; // Existing types — extend to accept daemon senders (not just desktop) // hub:permission_reply and hub:question_reply already route to targetDaemonDeviceId. // Currently only handled in the desktop client section — add to daemon section too: case 'hub:permission_reply': case 'hub:question_reply': this.routeDaemonToDaemon(client, msg); break;
routeDaemonToDaemon implementation:
private routeDaemonToDaemon(source: AuthenticatedClient, msg: HubMessage): void { const targetDeviceId = (msg as { targetDaemonDeviceId?: string }).targetDaemonDeviceId; if (!targetDeviceId) { this.sendError(source.ws, 'Missing targetDaemonDeviceId', msg.requestId); return; } const target = this.daemonClients.get(targetDeviceId); if (!target) { this.sendError(source.ws, 'Target daemon not connected', msg.requestId); return; } // Cross-user isolation if (target.userId !== source.userId) { this.sendError(source.ws, 'Access denied', msg.requestId); return; } this.sendMessage(target.ws, msg); }
Hub disconnect cleanup: When a daemon disconnects, the Hub already broadcasts hub:daemon_status with status: 'offline'. Source daemons listen for this event and reject any pending remoteTaskHandlers targeting the disconnected device — so the task tool's promise fails fast instead of hanging until timeout. See Source Daemon section.
Target Daemon — Execution + Streaming + Permission Forwarding
handleRemoteTaskSubmit mirrors handleTaskSubmit (lines 369-665 of hub/client.ts). The target daemon handles daemon-originated tasks identically to desktop-originated tasks. The key difference: progress, permissions, and questions route to the source daemon instead of a desktop.
New state on DaemonHubClient:
// Parallel to taskSourceDesktop — tracks which daemon originated each task private taskSourceDaemon: Map<string, { deviceId: string; requestId: string }> = new Map();
Execution handler:
case 'hub:daemon_task_submit': this.handleRemoteTaskSubmit(msg as HubDaemonTaskSubmitMessage); break;
handleRemoteTaskSubmit implementation:
- Creates session with
sessionType: 'remote_task', title[remote] {prompt preview} - Stores
taskSourceDaemon.set(taskId, { deviceId: msg.sourceDaemonDeviceId, requestId: msg.requestId }) - Runs
Bot0Agent.run()withonProgresscallback that sendshub:daemon_task_progressto source daemon - On completion, sends
hub:daemon_task_resultwithsourceDaemonRequestIdfor correlation - Uses
sendDaemonProgress()helper (mirrorssendProgress()but useshub:daemon_task_progresstype and addssourceDaemonRequestId)
Permission + Question event forwarding:
subscribeToEvents() already iterates taskSourceDesktop to route permission/question events to desktops. Extend it to also check taskSourceDaemon:
private subscribeToEvents(): void { this.permissionUnsubscribe = bus.subscribe(PermissionAsked, (event) => { const payload = event.properties; // Check desktop-originated tasks first (existing) for (const [taskId, desktopDeviceId] of this.taskSourceDesktop) { const taskState = this.activeTasks.get(taskId); if (taskState && !taskState.aborted) { // ... existing logic: send hub:task_progress to desktop ... return; } } // Check daemon-originated tasks for (const [taskId, source] of this.taskSourceDaemon) { const taskState = this.activeTasks.get(taskId); if (taskState && !taskState.aborted) { this.sendDaemonProgress(source.deviceId, source.requestId, taskId, { progressType: 'permission_request', permissionRequestId: payload.requestId, tool: payload.toolName, permission: payload.permission, permissionPatterns: payload.patterns, metadata: payload.metadata, }); return; } } }); // Same pattern for QuestionAsked → send question_request to source daemon this.questionUnsubscribe = bus.subscribe(QuestionAsked, (event) => { const payload = event.properties; // ... check taskSourceDesktop first (existing) ... for (const [taskId, source] of this.taskSourceDaemon) { const taskState = this.activeTasks.get(taskId); if (taskState && !taskState.aborted) { this.sendDaemonProgress(source.deviceId, source.requestId, taskId, { progressType: 'question_request', questionRequestId: payload.requestId, questions: payload.questions, }); return; } } }); }
Permission + Question reply handlers:
Reuses existing hub:permission_reply and hub:question_reply message types. The target daemon already handles these — the existing handlePermissionReply calls permissionManager.reply() and handleQuestionReply calls questionManager.reply(). No new handlers needed on the target daemon. The reply arrives as the same message type regardless of whether a desktop or daemon sent it.
// ALREADY EXISTS in handleMessage — no changes needed: case 'hub:permission_reply': const permMsg = msg as HubPermissionReplyMessage; permissionManager.reply(permMsg.permissionRequestId, permMsg.reply, permMsg.message); break; case 'hub:question_reply': const qMsg = msg as HubQuestionReplyMessage; questionManager.reply(qMsg.questionRequestId, qMsg.answers); break;
Helper for sending daemon-to-daemon progress:
private sendDaemonProgress( targetDaemonDeviceId: string, sourceDaemonRequestId: string, taskId: string, fields: Partial<HubDaemonTaskProgressMessage>, ): void { this.send({ type: 'hub:daemon_task_progress', requestId: generateId(), timestamp: Date.now(), targetDaemonDeviceId, sourceDaemonRequestId, taskId, progressType: fields.progressType ?? 'assistant', content: fields.content, tool: fields.tool, input: fields.input, result: fields.result, error: fields.error, tokenCount: fields.tokenCount, inputTokens: fields.inputTokens, outputTokens: fields.outputTokens, contextTokens: fields.contextTokens, metadata: fields.metadata, permissionRequestId: fields.permissionRequestId, permission: fields.permission, permissionPatterns: fields.permissionPatterns, questionRequestId: fields.questionRequestId, questions: fields.questions, }); }
Source Daemon — Progress Display + Permission/Question Proxying
The source daemon's task tool and Hub client work together to give the user full visibility into the remote task.
Hub client receives progress and routes to task tool callbacks:
// In DaemonHubClient: // Registered by task tool for each active remote task (keyed by requestId) private remoteTaskHandlers: Map<string, { resolve: (result: DaemonTaskResult) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; onProgress: (update: ProgressUpdate) => void; onPermission: (requestId: string, tool: string, permission: string, patterns?: string[]) => void; onQuestion: (requestId: string, questions: Array<{ question: string; header: string; options: Array<{ label: string; description: string }>; multiple?: boolean }>) => void; }> = new Map(); // In handleMessage: case 'hub:daemon_task_progress': this.handleRemoteTaskProgress(msg as HubDaemonTaskProgressMessage); break; case 'hub:daemon_task_result': this.handleRemoteTaskResult(msg as HubDaemonTaskResultMessage); break; private handleRemoteTaskProgress(msg: HubDaemonTaskProgressMessage): void { const handler = this.remoteTaskHandlers.get(msg.sourceDaemonRequestId); if (!handler) return; if (msg.progressType === 'permission_request') { handler.onPermission( msg.permissionRequestId!, msg.tool ?? '', msg.permission ?? '', msg.permissionPatterns, ); } else if (msg.progressType === 'question_request') { handler.onQuestion(msg.questionRequestId!, msg.questions!); } else { handler.onProgress({ type: msg.progressType as ProgressUpdate['type'], content: msg.content, tool: msg.tool, input: msg.input, result: msg.result, error: msg.error, tokenCount: msg.tokenCount, inputTokens: msg.inputTokens, outputTokens: msg.outputTokens, contextTokens: msg.contextTokens, metadata: msg.metadata, }); } } private handleRemoteTaskResult(msg: HubDaemonTaskResultMessage): void { const handler = this.remoteTaskHandlers.get(msg.sourceDaemonRequestId); if (handler) { clearTimeout(handler.timer); this.remoteTaskHandlers.delete(msg.sourceDaemonRequestId); handler.resolve(msg as DaemonTaskResult); } }
Hub disconnect cleanup — fail-fast on target daemon offline:
// In handleMessage, when receiving hub:daemon_status: case 'hub:daemon_status': const statusMsg = msg as HubDaemonStatusMessage; if (statusMsg.status === 'offline') { // Reject any pending remote tasks targeting the disconnected daemon for (const [requestId, handler] of this.remoteTaskHandlers) { // Check if this handler's target is the disconnected device // (targetDeviceId stored alongside handler at registration time) if (handler.targetDeviceId === statusMsg.deviceId) { clearTimeout(handler.timer); this.remoteTaskHandlers.delete(requestId); handler.reject(new Error(`Target daemon disconnected (device: ${statusMsg.deviceId})`)); } } } break;
This requires adding targetDeviceId: string to the remoteTaskHandlers map value (set during submitRemoteTask).
submitRemoteTask — called by task tool:
async submitRemoteTask(opts: { targetDeviceId: string; prompt: string; context?: string; timeoutMs?: number; onProgress: (update: ProgressUpdate) => void; onPermission: (requestId: string, tool: string, permission: string, patterns?: string[]) => void; onQuestion: (requestId: string, questions: Array<{ question: string; header: string; options: Array<{ label: string; description: string }>; multiple?: boolean }>) => void; }): Promise<DaemonTaskResult> { const requestId = generateId(); const timeoutMs = opts.timeoutMs ?? 300_000; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.remoteTaskHandlers.delete(requestId); reject(new Error(`Remote task timed out after ${timeoutMs / 1000}s`)); }, timeoutMs); this.remoteTaskHandlers.set(requestId, { resolve, reject, timer, targetDeviceId: opts.targetDeviceId, onProgress: opts.onProgress, onPermission: opts.onPermission, onQuestion: opts.onQuestion, }); this.send({ type: 'hub:daemon_task_submit', requestId, targetDaemonDeviceId: opts.targetDeviceId, sourceDaemonDeviceId: this.deviceId, prompt: opts.prompt, context: opts.context, timestamp: Date.now(), }); }); }
Helpers for sending replies back to target daemon (reuse existing types):
sendRemotePermissionReply(opts: { targetDaemonDeviceId: string; permissionRequestId: string; reply: 'once' | 'always' | 'reject'; message?: string; }): void { // Reuse existing hub:permission_reply — same shape, Hub routes to targetDaemonDeviceId this.send({ type: 'hub:permission_reply', requestId: generateId(), timestamp: Date.now(), ...opts, }); } sendRemoteQuestionReply(opts: { targetDaemonDeviceId: string; questionRequestId: string; answers: string[][]; }): void { // Reuse existing hub:question_reply — same shape, Hub routes to targetDaemonDeviceId this.send({ type: 'hub:question_reply', requestId: generateId(), timestamp: Date.now(), ...opts, }); }
Task Tool — Remote Code Path (Full Implementation)
// In task tool execute(), remote path: if (input.target_daemon) { // ── Hard guard: never delegate in skill_builder or plan mode ── const ctx = getTaskContext(); const agentMode = ctx?.agentMode; if (agentMode === 'skill_builder' || agentMode === 'plan') { return 'Cannot delegate to remote daemons in skill_builder or plan mode. These modes require local execution for interactive collaboration with the user.'; } const hubClient = deps.getHubClient(); if (!hubClient?.isConnected()) { return 'Error: Hub connection required for remote task execution.'; } const parentCtx = getTaskContext(); const parentEmit = parentCtx?.onToolMetadata; let childToolCount = 0; const startTime = Date.now(); // Emit initial metadata for UI (same as local subagent) const remoteSessionId = `remote-${generateId()}`; parentEmit?.('task', { childSessionId: remoteSessionId, agentName: `remote:${input.target_daemon.slice(0, 8)}`, } as unknown as Record<string, unknown>); const result = await hubClient.submitRemoteTask({ targetDeviceId: input.target_daemon, prompt: input.prompt, context: input.context, timeoutMs: (input.timeout ?? 300) * 1000, // ── Streaming: forward to desktop via TaskToolMetadata ── onProgress: (update) => { if (update.type === 'tool_call') childToolCount++; // Forward tool calls and results for live UI display // (same pattern as local subagent's onProgress in task.ts:276-288) if (update.type === 'tool_call' || update.type === 'tool_result') { parentEmit?.('task', { childSessionId: remoteSessionId, agentName: `remote:${input.target_daemon.slice(0, 8)}`, childEvent: update, } as unknown as Record<string, unknown>); } }, // ── Permissions: route through source daemon's permission system ── // IMPORTANT: Permission requestId mapping // The target daemon's permissionRequestId (targetReqId) arrives via onPermission. // We create a NEW local permission request via the source daemon's permissionManager // (which generates its own internal requestId for the desktop UI prompt). // When the user approves/denies, the local promise resolves. // We then forward the user's decision back using the TARGET daemon's original // targetReqId — not the source daemon's internal ID. onPermission: async (targetReqId, tool, permission, patterns) => { // permissionManager handles: serialization, desktop UI prompt, auto-mode, rules // The user sees the permission prompt on their desktop — identical UX to local tools const reply = await permissionManager.requestPermission({ // Generate a fresh local requestId (don't reuse targetReqId to avoid ID collisions) toolName: tool, permission, patterns, metadata: { remote: true, targetDaemon: input.target_daemon }, }); // Forward user's decision back to the target daemon using the TARGET's original requestId hubClient.sendRemotePermissionReply({ targetDaemonDeviceId: input.target_daemon, permissionRequestId: targetReqId, reply: reply.decision, message: reply.message, }); }, // ── Questions: same requestId mapping pattern as permissions ── onQuestion: async (targetReqId, questions) => { // questionManager handles: desktop UI display, user response collection const answers = await questionManager.ask({ questions }); hubClient.sendRemoteQuestionReply({ targetDaemonDeviceId: input.target_daemon, questionRequestId: targetReqId, answers, }); }, }); const elapsedMs = Date.now() - startTime; // Emit done metadata for UI parentEmit?.('task', { childSessionId: remoteSessionId, agentName: `remote:${input.target_daemon.slice(0, 8)}`, done: true, toolCount: childToolCount, elapsedMs, inputTokens: result.inputTokens ?? 0, outputTokens: result.outputTokens ?? 0, } as unknown as Record<string, unknown>); // Return same <task_result> XML format return { output: `<task_result> <task_id>${remoteSessionId}</task_id> <agent>remote</agent> <target_daemon>${input.target_daemon}</target_daemon> <remote>true</remote> <description>${input.description}</description> <tools_used>${childToolCount}</tools_used> <tokens_in>${result.inputTokens ?? 0}</tokens_in> <tokens_out>${result.outputTokens ?? 0}</tokens_out> <elapsed>${(elapsedMs / 1000).toFixed(1)}s</elapsed> <result> ${result.text ?? result.error ?? 'No response from remote agent'} </result> </task_result>`, metadata: { type: 'task', childSessionId: remoteSessionId, agentName: 'remote', toolCount: childToolCount, elapsedMs, inputTokens: result.inputTokens ?? 0, outputTokens: result.outputTokens ?? 0, done: true, remote: true, targetDaemon: input.target_daemon, } as ToolResultMetadata, }; } else { // ── Local path (existing behavior, unchanged) ── // ... spawn child Bot0Agent in-process }
Desktop UI — Zero Changes Required
The desktop already renders StreamingTaskResult for local subagent TaskToolMetadata events. Since the remote path emits the same TaskToolMetadata shape, no desktop UI changes are needed. The user sees:
┌─────────────────────────────────────────────────────┐
│ ▸ task: remote:abc123ab │
│ ├─ bash: npm run test │
│ ├─ read: src/api/handler.ts │
│ ├─ [Permission] bash: rm -rf dist/ [Allow] │
│ └─ edit_file: src/api/handler.ts │
│ ✓ 4 tools · 2,450 in · 890 out · 12.3s │
└─────────────────────────────────────────────────────┘
Permission prompts appear in the existing PermissionPrompt component. Question prompts appear in the existing QuestionPrompt component. The { remote: true, targetDaemon } metadata could optionally add a visual indicator (e.g., "[atlas]" badge) in a future UI enhancement.
Error Handling
| Error | Behavior |
|---|---|
| Target daemon offline | Tool returns error immediately (Hub sends hub:error) |
| Target daemon disconnects mid-task | Hub broadcasts hub:daemon_status offline → source daemon rejects pending handler immediately (no timeout wait) |
| Task timeout | Promise rejected after timeout seconds, handler cleanup |
| No Hub connection | Tool returns error: "Hub connectivity required for remote tasks" |
| Permission denied by user | Reply forwarded to target daemon → agent gets rejection feedback |
| Question dismissed by user | Reply forwarded with empty answers |
| Skill_builder / plan mode | Task tool rejects target_daemon with hard guard (before any Hub communication) |
| Target daemon busy | Future: capacity-based rejection. MVP: always accepts |
Task Tool Dependencies Update
The existing createTaskTool(deps) factory needs additional deps for the remote path:
interface TaskToolDeps { // ... existing deps getParentRegistry: () => ToolRegistry; getMiddlewares: () => ToolMiddleware[]; getSessionManager: () => SessionManager | null; getSigningProvider: () => SigningProvider | null; // NEW — for remote execution getHubClient: () => DaemonHubClient | null; getConfig: () => DaemonConfig; }
System Prompt Integration
Current State
The system prompt (packages/daemon/src/agent/loop.ts:82-113) includes:
- Daemon name, CWD, home directory
- Tool usage guidelines
- Project instructions (AGENTS.md / CLAUDE.md)
Missing: No awareness of daemon role, network, or delegation guidance.
Proposed Addition
Add after the tool usage section, before project instructions:
// In buildSystemPrompt(): const deviceRole = config.deviceRole ?? 'daily-driver'; const daemonId = config.daemonId; let networkPrompt = ''; if (deviceRole === 'daily-driver') { networkPrompt = ` ## Daemon Network You are running on **${name}**, a daily driver machine. This is the user's primary computer — they are actively using it. You have access to a network of bot0 daemons via the \`daemon_network\` tool and the \`task\` tool's \`target_daemon\` parameter. **Delegation guidelines:** - Before running browser automation (dom0), desktop automation (cmd0), or long-running tasks, consider whether a 24/7 agent daemon could handle it instead. - Use \`daemon_network\` to search for capable daemons, then \`task\` with \`target_daemon\` to delegate. - This keeps the user's screen, browser, and apps undisturbed. - If no suitable 24/7 daemon is available, execute locally but warn the user that their screen/browser may be affected. **When to delegate:** - dom0 tasks (browser automation) — prefer delegating to a 24/7 agent - cmd0 tasks (desktop automation) — prefer delegating to a 24/7 agent - Skill execution that involves dom0/cmd0 — prefer delegating - Long-running data processing — prefer delegating - Quick local file operations (read, edit, grep) — execute locally **NEVER delegate (always run locally):** - Skill building / skill_builder mode — this is interactive co-design with the user - Plan mode — the user is collaborating on an implementation plan - Conversational tasks — questions, discussions, explanations - The user explicitly asks you to do something on THIS machine - The task involves files/repos that only exist on this machine - Quick, non-disruptive operations (file reads, git status, etc.) `; } else { networkPrompt = ` ## Daemon Network You are running on **${name}**, a dedicated 24/7 agent. Execute tasks directly — this machine is purpose-built for automation. You can use \`daemon_network\` to discover other daemons and \`task\` with \`target_daemon\` to coordinate multi-daemon work when a task requires capabilities on a different machine (e.g., a specific app, browser account, or codebase). **NEVER delegate (always run locally):** - Skill building / skill_builder mode — this is interactive co-design with the user - Plan mode — the user is collaborating on an implementation plan - Conversational tasks — questions, discussions, explanations `; } prompt += networkPrompt;
Why This Approach Works
- Not a hard rule, a guideline — The agent reasons about delegation, it doesn't blindly delegate everything. If the user says "open LinkedIn on my computer", it respects that.
- Lightweight — No pre-task forced tool call. The agent reads the system prompt and decides. If a simple question comes in ("what time is it?"), it answers directly without touching
daemon_network. - Composable — Works with
scheduletool too. Agent can find a daemon viadaemon_network, then create a recurring cron viascheduletargeting it. - Protects interactive modes — Skill building and plan mode are inherently collaborative between the user and the agent. Delegating them would break the feedback loop. The agent always runs these locally regardless of daemon role. The task tool's hard guard enforces this even if the LLM ignores the system prompt.
Alternative Considered: Forced Pre-Task Tool Call
You mentioned the agent should "call this botnet_search tool before starting a task." I'd recommend against forcing this and instead making it a system prompt guideline, because:
- Most tasks don't need delegation — "What's in this file?" doesn't need a network scan.
- Adds latency — An extra Haiku call + proxy round-trip before every task.
- Agent can reason — When it sees a dom0/cmd0 tool call coming, it can check the network at that point, not preemptively.
The system prompt instruction gives the agent the right instinct without the overhead. It will naturally call daemon_network when it recognizes a delegatable task.
Schema Changes
Modify ctx0_daemons Table
New columns:
| Column | Type | Default | Purpose |
|---|---|---|---|
description | text | null | Human-readable purpose (e.g., "CI/CD and code review agent") |
category | text | null | Organizational category (engineering, marketing, sales, ops, personal) |
skills | jsonb | [] | Synced skill names + descriptions from filesystem |
Add to packages/ctx0/src/schema/devices.ts:
description: text('description'), category: text('category'), skills: jsonb('skills').default([]),
Skill Sync Mechanism
Skills are discovered from the filesystem at runtime. To make them visible across the network, each daemon syncs its loaded skills to the DB:
When to sync:
- On daemon startup (after skill discovery)
- When skill cache refreshes (every 5 seconds if CWD changes)
- On explicit
/discoverordiscovertool call
Sync endpoint:
- Reuse existing
POST /api/proxy/daemons/updateor add to the discover data sync - Store as:
[{ name: "git-review", description: "..." }, ...]
Where skills come from per daemon:
- Project skills:
{cwd}/.bot0/skills/*/SKILL.md - Global skills:
~/.bot0/skills/*/SKILL.md - Each daemon has different CWD → different project skills
Session Type Addition
Add 'remote_task' to the session type system:
In packages/daemon/src/session/manager.ts:
- Filter
'remote_task'from resume chain ingetLatestSessionInChain()(same as'task_child')
Daemon Config Changes
Modify packages/daemon/src/config.ts:
The daemon needs to know its own role and ID for system prompt construction and tool availability.
interface DaemonConfig { // ... existing fields daemonId?: string; // Set during daemon registration deviceRole?: 'daily-driver' | 'agent'; // Set during daemon registration }
These are populated during the daemon registration flow (when the daemon first connects and registers with the proxy).
Implementation Details
New Files
| File | Package | Purpose |
|---|---|---|
packages/daemon/src/tools/daemon-network.ts | daemon | daemon_network tool implementation |
Modified Files
| File | Change |
|---|---|
packages/core/src/types/hub-protocol.ts | Add 3 new daemon-to-daemon message types + add to HubMessage union |
apps/hub/src/index.ts | Add routeDaemonToDaemon(), handle 3 new + 2 existing message types from daemon senders |
packages/daemon/src/hub/client.ts | taskSourceDaemon map, remoteTaskHandlers map (with targetDeviceId for disconnect cleanup), handleRemoteTaskSubmit(), handleRemoteTaskProgress(), handleRemoteTaskResult(), submitRemoteTask(), sendRemotePermissionReply() (reuses hub:permission_reply), sendRemoteQuestionReply() (reuses hub:question_reply), sendDaemonProgress(), extend subscribeToEvents(), handle hub:daemon_status offline for fail-fast |
packages/daemon/src/tools/task.ts | Add target_daemon + timeout to input schema, hard guard for skill_builder/plan mode, add remote code path with streaming/permission/question callbacks, permission requestId mapping |
packages/daemon/src/agent/loop.ts | Expand buildSystemPrompt() with daemon network awareness + never-delegate rules for skill/plan mode |
packages/daemon/src/agent/agents.ts | Add 'daemon_network' to deniedTools for all subagents (task tool already excluded) |
packages/daemon/src/tools/index.ts | Export createDaemonNetworkTool |
packages/daemon/src/index.ts | Register daemon_network tool, pass getHubClient + getConfig to task tool deps |
packages/daemon/src/config.ts | Add daemonId, deviceRole to DaemonConfig |
packages/daemon/src/session/manager.ts | Filter 'remote_task' from resume chain |
packages/ctx0/src/schema/devices.ts | Add description, category, skills columns to ctx0_daemons |
packages/ctx0/src/schema/types.ts | Add type exports for new columns |
Relationship to Cron Scheduler
The daemon_network tool replaces the network action originally planned in the schedule tool. Instead of duplicating daemon discovery logic in the schedule tool, the workflow becomes:
- Agent calls
daemon_network({ action: "search", query: "24/7 daemon for daily LinkedIn checks" }) - Gets back matching daemon(s) with IDs
- Calls
schedule({ action: "create", target_daemon: "abc123", ... })
The schedule tool no longer needs its own network action — it just takes a target_daemon ID (found via daemon_network).
Similarly, the Hub daemon-to-daemon routing and handleRemoteTaskSubmit handler established here are reused by the cron scheduler's Hub dispatch path. The schedule/executor.ts shared execution logic can be based on the same handleRemoteTaskSubmit pattern.
Implementation Order
1. Schema changes (ctx0_daemons columns) ← no dependencies
2. Hub protocol (3 new message types) ← depends on @bot0/core types
3. Hub routing (daemon-to-daemon + existing types) ← depends on protocol
4. daemon_network tool ← depends on schema
5. Daemon Hub client (full streaming infra) ← depends on Hub routing
- Target: handleRemoteTaskSubmit + sendDaemonProgress + subscribeToEvents extension
- Source: remoteTaskHandlers + handleRemoteTaskProgress + handleRemoteTaskResult
- Source: sendRemotePermissionReply (reuses hub:permission_reply) + sendRemoteQuestionReply (reuses hub:question_reply)
- Source: hub:daemon_status offline → fail-fast handler cleanup
6. task tool remote path (target_daemon) ← depends on Hub client
- Hard guard for skill_builder/plan mode
- Permission requestId mapping (target vs source IDs)
7. System prompt integration ← depends on config changes
8. Skill sync mechanism ← independent, can parallel with 4-6
Phases 4 and 5-6 can overlap. Phase 8 is independent.
This system should be built BEFORE the cron scheduler since:
daemon_networkreplaces the schedule tool'snetworkaction- The
tasktool's remote execution path establishes the cross-daemon pattern reused by scheduled task execution - The Hub daemon-to-daemon routing is needed by both
Future Enhancements
Category-Based Routing
Add daemon categories to enable faster, more targeted matching:
engineering: CI/CD, code review, testing
marketing: content, social media, analytics
sales: CRM, outreach, reporting
ops: monitoring, deployment, infrastructure
personal: daily tasks, email, calendar
The agent can filter by category before Haiku matching, reducing the profile set for large fleets.
Capacity-Based Delegation
Track daemon workload (active tasks, queue depth) and factor it into delegation decisions. A busy 24/7 daemon might reject or queue tasks.
Multi-Daemon Fan-Out
The agent sends the same task to multiple daemons in parallel and aggregates results. Useful for distributed data gathering or parallel code reviews.
Daemon-to-Daemon Skill Sharing
A daemon can request a skill from another daemon's filesystem and load it temporarily for a task.
Verification
- Schema:
pnpm db:status— verify new columns detected onctx0_daemons - Hub:
pnpm --filter @bot0/hub build— verify daemon-to-daemon routing compiles - Hub routing: Verify
hub:permission_replyandhub:question_replyaccepted from daemon senders (not just desktop) - daemon_network: Create tool, verify
listreturns daemon profiles from proxy, verifysearchcalls Haiku and returns matches - Streaming:
task({ target_daemon: "..." })→ verifyhub:daemon_task_progressevents flow back → verify desktop shows live tool calls inStreamingTaskResult - Permissions: Remote agent needs bash permission → verify
hub:daemon_task_progresswithpermission_requestflows to source daemon → source daemon creates local permission request (new requestId) → showsPermissionPromptto user → user approves →hub:permission_replyflows back with target daemon's original requestId → remote agent continues - Questions: Remote agent asks question → same flow via
question_request/hub:question_replywith requestId mapping - System prompt: Verify daily driver prompt includes delegation guidelines + never-delegate rules for skill/plan mode
- Hard guard: Agent in skill_builder mode calls
task({ target_daemon: "..." })→ verify tool rejects immediately (before Hub communication) - Hard guard: Agent in plan mode calls
task({ target_daemon: "..." })→ verify tool rejects immediately - E2E delegation: Daily driver agent receives dom0 task → calls
daemon_networkto find 24/7 daemon with CDP → callstaskwithtarget_daemonto delegate → sees live streaming → approves permission → receives result - Offline handling:
taskwithtarget_daemontargeting offline daemon → verify immediate error (not hung promise) - Disconnect mid-task: Target daemon disconnects during execution → verify source daemon receives
hub:daemon_statusoffline → promise rejected immediately (no timeout wait) - Timeout: Remote task exceeds timeout → verify promise rejected, handler cleaned up
- Skill sync: Daemon starts → skills synced to DB → visible in
daemon_network listoutput - Cross-user isolation: Daemon A (user 1) tries to send task to Daemon B (user 2) → verify Hub rejects