The Domestic Automation Daemon started as a single agent handling Telegram messages, system health checks, and household reminders. One brain, one context window, one thread of attention. It worked—until the range of tasks outgrew what a single agent could hold in its head at once.
The limitation wasn't capability. It was focus. Asking one agent to troubleshoot a failed service, draft a web page, validate a screenshot, and log the results meant context thrash, fractured attention, and unnecessarily long conversations. The obvious answer: specialise. Give each concern its own agent with its own tools, memory, and surface area.
That raised a second question: how do you coordinate them?
The cast
The system currently has five specialised agents, each with a clear boundary:
| Agent | Purpose |
|---|---|
sysadmin-agent | Infrastructure, debugging, system health |
web-publisher | Content editing, design system, static pages |
web-developer | Apps, backend services, Caddy routing |
ui-validator | Visual page validation at multiple viewports |
home-agent | Family queries, domestic task coordination |
Each agent has its own system prompt, toolset, model configuration, and working directory. They share nothing by default—no global state, no overlapping responsibilities. The orchestration layer is the thing that connects them safely.
Two delegation modes
Synchronous (in-process)
The simplest path: the parent agent calls delegate_to_agent with an agent name and a task. The sub-agent runs inside the same Node.js process via the pi SDK's createAgentSession. The parent receives live progress updates showing tool calls as they happen.
If the parent crashes, the sub-agent dies with it—no orphaned processes, no zombie sessions. This is the right choice when the parent needs the result before continuing and the subtask is bounded in duration.
delegate_to_agent({
agent: "ui-validator",
task: "Validate the homepage at 1440px, 768px, and 390px"
})
Asynchronous (fire-and-forget)
Some tasks don't need an immediate answer. Draft a blog post. Scan a log file. Generate a screenshot. For these, the orchestrator spawns a lightweight child process via pi_agent_runner_oneshot.ts and returns a task ID immediately.
The parent can poll progress with check_delegation, or simply wait—completed results are auto-injected into the conversation on the next turn. This keeps the parent responsive while background work proceeds independently.
delegate_to_agent({
agent: "web-publisher",
task: "Write an article about the orchestration system",
mode: "async"
})
DAG execution
Real workflows aren't linear. You can't validate a page before it's published. You can't take a screenshot before the page exists. The orchestration layer supports directed acyclic graphs—tasks with explicit dependencies that the system resolves automatically.
Leaf nodes launch immediately. Dependent nodes wait for their parents to complete, then fire. The scheduler handles the coordination transparently.
delegate_to_agent({
mode: "dag",
dag: [
{ id: "publish", agent: "web-publisher", task: "Write article" },
{ id: "validate", agent: "ui-validator", task: "Validate page",
depends_on: ["publish"] },
{ id: "screenshot", agent: "ui-validator", task: "Screenshot",
depends_on: ["publish"] },
{ id: "notify", agent: "web-developer", task: "Log results",
depends_on: ["validate", "screenshot"] }
]
})
This is the pattern used for publishing a new article: write, validate, screenshot, notify—each step waiting only on what it actually needs.
Shared workspace
When agents work independently, they need somewhere to leave artifacts for each other. Each DAG run creates a shared folder at agents/_async/shared/dag_xxx/ that persists through the graph lifecycle. Sub-agents read upstream output and write their own summaries.
Old folders are swept when a new DAG launches. The shared workspace is a coordination primitive, not a document store—ephemeral by design.
Live dashboard
Running agents need visibility. A persistent widget above the editor shows every active agent in real time:
⚡ Agents
🔄 web-publisher [article] 12s T3 7t 4k 2.1s edit
⏳ ui-validator [validate] · waiting on: article
Updated every two seconds and on every conversation turn, the dashboard shows: agent name, DAG node ID, age, turn count, tool count, token usage, timing, and current tool. Completed tasks show result summaries. It turns the invisible work of delegation into something you can watch and trust.
Architecture
delegate_to_agent
├── sync → createAgentSession (in-process pi SDK)
└── async → spawn pi_agent_runner_oneshot.ts (child process)
└── writes status JSON to agents/_async/
├── 2s poll reads status → updates dashboard widget
├── turn_start → injects completed results into chat
└── DAG scheduler → launches pending nodes when deps ready
What makes it work
Three properties matter more than features:
No orphan processes. Sync sessions share the parent process lifecycle. Async children are tracked by PID and die independently. A crashed orchestrator never leaks sub-agents into the background.
Swap-backed resilience. The system runs with 1GB of swap to absorb memory spikes during visual validation. Puppeteer and Chrome are the hungriest citizens in the process group—swap gives them room without starving the rest of the system.
Session token auth. The URL aggregator API is protected by rotating per-page-load tokens and Caddy basic_auth. Internal services don't rely on network boundaries alone.
What's next
The orchestration layer opened a path I hadn't fully anticipated: multi-agent workflows that feel like pairing with a small team rather than operating a single tool. The next interesting direction is giving agents the ability to spawn new agents for emergent tasks—not from a fixed roster, but from a description of the work needed.
But that's a build log for another day.
Filed under: agentic systems, infrastructure, building in the open.