daemon-network-intelligence-system.md

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:

  1. 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).
  2. task tool extended with target_daemon — The existing local subagent tool gains an optional target_daemon parameter. 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:

  1. Daily driver disruption — dom0/cmd0 tasks run on the user's machine, hijacking their browser, moving windows, running scripts while they work.
  2. 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

ActionPurposeRequired Params
listList all daemons with summary profiles
searchSemantic search using Haiku matchingquery
profileGet full discover profile of a specific daemondaemon_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:

FieldSourceDescription
idctx0_daemons.idDaemon UUID
namectx0_daemons.nameHuman name (e.g., "atlas")
deviceIdctx0_daemons.device_idDevice identifier
rolectx0_daemons.device_role'daily-driver' or 'agent' (24/7)
descriptionctx0_daemons.descriptionNEW — Purpose description
categoryctx0_daemons.categoryNEW — Org category (engineering, marketing, etc.)
onlinehub_connection_id IS NOT NULLLive connection status
lastSeenAtctx0_daemons.last_seen_atLast activity timestamp
Discover Profilectx0_daemons.discovery_data
appsdiscovery_data.appsInstalled + running apps with frameworks, dom0/cmd0 routing
savedLoginsdiscovery_data.savedLoginsChrome saved login domains (linkedin.com, github.com, etc.)
cdpConnectionsdiscovery_data.cdpActive Chrome DevTools Protocol connections
gitProjectsdiscovery_data.projectsGit repos with paths and last activity
browserTabsdiscovery_data.tabsOpen browser tabs
platformdiscovery_data.platformOS info (macOS, Linux, Windows)
Skillsctx0_daemons.skillsNEW — Synced skill names + descriptions
Toolsctx0_daemons.toolsLoaded 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

typescript
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:

typescript
// 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:

typescript
| '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 has targetDaemonDeviceId and 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 to targetDaemonDeviceId.

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:

typescript
// ─── 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).

typescript
// 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:

typescript
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:

typescript
// Parallel to taskSourceDesktop — tracks which daemon originated each task private taskSourceDaemon: Map<string, { deviceId: string; requestId: string }> = new Map();

Execution handler:

typescript
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() with onProgress callback that sends hub:daemon_task_progress to source daemon
  • On completion, sends hub:daemon_task_result with sourceDaemonRequestId for correlation
  • Uses sendDaemonProgress() helper (mirrors sendProgress() but uses hub:daemon_task_progress type and adds sourceDaemonRequestId)

Permission + Question event forwarding:

subscribeToEvents() already iterates taskSourceDesktop to route permission/question events to desktops. Extend it to also check taskSourceDaemon:

typescript
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.

typescript
// 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:

typescript
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:

typescript
// 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:

typescript
// 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:

typescript
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):

typescript
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)

typescript
// 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

ErrorBehavior
Target daemon offlineTool returns error immediately (Hub sends hub:error)
Target daemon disconnects mid-taskHub broadcasts hub:daemon_status offline → source daemon rejects pending handler immediately (no timeout wait)
Task timeoutPromise rejected after timeout seconds, handler cleanup
No Hub connectionTool returns error: "Hub connectivity required for remote tasks"
Permission denied by userReply forwarded to target daemon → agent gets rejection feedback
Question dismissed by userReply forwarded with empty answers
Skill_builder / plan modeTask tool rejects target_daemon with hard guard (before any Hub communication)
Target daemon busyFuture: capacity-based rejection. MVP: always accepts

Task Tool Dependencies Update

The existing createTaskTool(deps) factory needs additional deps for the remote path:

typescript
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:

typescript
// 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

  1. 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.
  2. 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.
  3. Composable — Works with schedule tool too. Agent can find a daemon via daemon_network, then create a recurring cron via schedule targeting it.
  4. 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:

ColumnTypeDefaultPurpose
descriptiontextnullHuman-readable purpose (e.g., "CI/CD and code review agent")
categorytextnullOrganizational category (engineering, marketing, sales, ops, personal)
skillsjsonb[]Synced skill names + descriptions from filesystem

Add to packages/ctx0/src/schema/devices.ts:

typescript
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 /discover or discover tool call

Sync endpoint:

  • Reuse existing POST /api/proxy/daemons/update or 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 in getLatestSessionInChain() (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.

typescript
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

FilePackagePurpose
packages/daemon/src/tools/daemon-network.tsdaemondaemon_network tool implementation

Modified Files

FileChange
packages/core/src/types/hub-protocol.tsAdd 3 new daemon-to-daemon message types + add to HubMessage union
apps/hub/src/index.tsAdd routeDaemonToDaemon(), handle 3 new + 2 existing message types from daemon senders
packages/daemon/src/hub/client.tstaskSourceDaemon 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.tsAdd 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.tsExpand buildSystemPrompt() with daemon network awareness + never-delegate rules for skill/plan mode
packages/daemon/src/agent/agents.tsAdd 'daemon_network' to deniedTools for all subagents (task tool already excluded)
packages/daemon/src/tools/index.tsExport createDaemonNetworkTool
packages/daemon/src/index.tsRegister daemon_network tool, pass getHubClient + getConfig to task tool deps
packages/daemon/src/config.tsAdd daemonId, deviceRole to DaemonConfig
packages/daemon/src/session/manager.tsFilter 'remote_task' from resume chain
packages/ctx0/src/schema/devices.tsAdd description, category, skills columns to ctx0_daemons
packages/ctx0/src/schema/types.tsAdd 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:

  1. Agent calls daemon_network({ action: "search", query: "24/7 daemon for daily LinkedIn checks" })
  2. Gets back matching daemon(s) with IDs
  3. 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_network replaces the schedule tool's network action
  • The task tool'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

  1. Schema: pnpm db:status — verify new columns detected on ctx0_daemons
  2. Hub: pnpm --filter @bot0/hub build — verify daemon-to-daemon routing compiles
  3. Hub routing: Verify hub:permission_reply and hub:question_reply accepted from daemon senders (not just desktop)
  4. daemon_network: Create tool, verify list returns daemon profiles from proxy, verify search calls Haiku and returns matches
  5. Streaming: task({ target_daemon: "..." }) → verify hub:daemon_task_progress events flow back → verify desktop shows live tool calls in StreamingTaskResult
  6. Permissions: Remote agent needs bash permission → verify hub:daemon_task_progress with permission_request flows to source daemon → source daemon creates local permission request (new requestId) → shows PermissionPrompt to user → user approves → hub:permission_reply flows back with target daemon's original requestId → remote agent continues
  7. Questions: Remote agent asks question → same flow via question_request / hub:question_reply with requestId mapping
  8. System prompt: Verify daily driver prompt includes delegation guidelines + never-delegate rules for skill/plan mode
  9. Hard guard: Agent in skill_builder mode calls task({ target_daemon: "..." }) → verify tool rejects immediately (before Hub communication)
  10. Hard guard: Agent in plan mode calls task({ target_daemon: "..." }) → verify tool rejects immediately
  11. E2E delegation: Daily driver agent receives dom0 task → calls daemon_network to find 24/7 daemon with CDP → calls task with target_daemon to delegate → sees live streaming → approves permission → receives result
  12. Offline handling: task with target_daemon targeting offline daemon → verify immediate error (not hung promise)
  13. Disconnect mid-task: Target daemon disconnects during execution → verify source daemon receives hub:daemon_status offline → promise rejected immediately (no timeout wait)
  14. Timeout: Remote task exceeds timeout → verify promise rejected, handler cleaned up
  15. Skill sync: Daemon starts → skills synced to DB → visible in daemon_network list output
  16. Cross-user isolation: Daemon A (user 1) tries to send task to Daemon B (user 2) → verify Hub rejects