← All essays· Engineering

Building an Agent Daemon

A practical build order for turning a terminal agent into a managed daemon with tools, runtime adapters, and OpenViking-backed memory.

An agent daemon is the missing layer between a terminal coding assistant and a collaborative product: it owns the local process, translates runtime-specific events, injects tools, and gives the agent a durable identity.

A CLI agent is designed for one human sitting at one terminal. That is the wrong boundary for a product like Zouk, where agents are colleagues: they receive messages while asleep, claim tasks, reply in threads, survive browser reloads, and carry memory across days. The daemon is not a smarter prompt. It is the operating boundary around the agent.

The shape below comes from building Zouk Daemon against several real runtimes: Claude Code stream-json, Codex app-server JSON-RPC, Hermes/Coco/OpenCode over ACP, plus custom HTTP-backed agents. The details differ, but the construction sequence stays stable.

Start With Process Ownership

The first job is boring and decisive: the daemon, not the web app and not the terminal, must own the child process. It chooses the working directory, environment, model, system prompt, MCP configuration, and resume token. It also decides when the process is allowed to die.

driver-contract.tsjs
export interface Driver {  id: string;  supportsStdinNotification: boolean;  busyDeliveryMode: "notification" | "direct" | "none";   spawn(ctx: SpawnContext): { process: ChildProcess };  parseLine(line: string): ParsedEvent[];  encodeStdinMessage(text: string, sessionId: string | null): string | null;  buildSystemPrompt(config: AgentConfig, agentId: string): string;}

That small interface is the useful abstraction. Everything runtime-specific stays inside the driver: Claude gets `--output-format stream-json`, Codex gets `thread/create` or `thread/resume`, Hermes gets `session/new` or `session/load`, and a custom service may just stream HTTP events. The rest of the daemon sees one process and one event stream.

Sessions Are Not Processes

A process is disposable. A session is the agent runtime’s conversation state. Good daemon behavior comes from keeping that distinction clear: kill the process on idle, keep the session id for persistent agents, clear it for ephemeral agents, and treat reset as “stop plus start without the old session.”

lifecycle.jsjs
async function stopProcess(child) {  if (!child || child.exitCode !== null) return;  child.kill("SIGTERM");  await waitForExit(child, 5000).catch(() => child.kill("SIGKILL"));} function cacheIdleAgent(agent) {  return {    config: { ...agent.config, sessionId: agent.sessionId },    workingDirectory: agent.workingDirectory,    lastActivity: agent.lastActivity,  };}

This is where many daemons become flaky. A new message can arrive while the previous idle process is still shutting down. A runtime may report a successful resume without echoing the session id. Some runtimes can accept a steering message mid-turn; others need a queue until `turn_end`. These are lifecycle rules, not UI rules, so they belong in the daemon.

same-agent-race.jsjs
const pendingStop = agentsStopping.get(agentId);if (pendingStop) await pendingStop; if (agents.has(agentId)) {  await stopAgent(agentId, { wait: true });} agentsStarting.set(agentId, startAgentProcess(agentId, config));

That guard looks small, but it prevents a subtle class of bugs: two processes believing they own the same agent slot, same workspace, and same session. A daemon is mostly a set of these small invariants.


Put The Network Boundary Above The Daemon

The product needs network access. The agent needs local access. Mixing those two facts is how you end up with browser tabs owning local shells, or servers trying to run commands on the wrong machine. Keep the server as a relay and the daemon as the local authority.

01

Web app

messages, tasks, UI state
02

Server

auth, routing, fan-out
owner

Daemon

local process owner
04

Runtime driver

Claude, Codex, ACP...
05

Agent CLI

files, shell, tools

Server responsibilities

  • Authenticate humans and agents.
  • Route channel, DM, thread, and task messages.
  • Broadcast status and activity to clients.

Daemon responsibilities

  • Resolve the local workspace and runtime config.
  • Start, stop, idle-cache, and resume processes.
  • Translate raw runtime output into product events.
wire-events.jsonjs
{ "type": "agent:start", "agentId": "agent-louise", "config": { "runtime": "codex" } }{ "type": "agent:deliver", "agentId": "agent-louise", "message": "Fix task #82" }{ "type": "agent:activity", "agentId": "agent-louise", "activity": "Reading files..." }{ "type": "agent:status", "agentId": "agent-louise", "status": "idle" }

Reconnect Should Be Boring

The daemon connects outward to the server, so it should assume the socket will drop. On reconnect it can re-announce detected runtimes, capabilities, running sessions, idle-cached sessions, and the last known activity for each agent. That makes a browser reload or network hiccup a reconciliation problem, not an agent restart.

Normalize The Runtime Stream

Every useful runtime emits a different stream. Claude sends `system`, `assistant`, and `result` records. Codex uses thread events with item updates. ACP runtimes send JSON-RPC updates like `session/update`, `tool_call`, and `usage_update`. If those shapes leak upward, every product feature becomes runtime-specific.

parsed-event.tsjs
type ParsedEvent =  | { kind: "session_init"; sessionId: string }  | { kind: "thinking"; text: string }  | { kind: "text"; text: string }  | { kind: "tool_call"; name: string; input?: unknown }  | { kind: "context_usage"; contextUsage: ContextUsageSnapshot }  | { kind: "turn_end" }  | { kind: "error"; message: string };
Raw signalDaemon eventProduct behavior
system.initsession_initStore the session id for resume.
assistant.text_deltatextAppend visible assistant output.
tool_calltool_callShow activity, summarize inputs, keep users oriented.
usage_updatecontext_usageRender token/context pressure before the turn ends.
resultturn_endFlush queued messages and transition to idle.

The important part is not the names. It is that the product has one vocabulary for “the agent is thinking,” “the agent is calling a tool,” “the context window is filling,” and “the turn is finished.” That is what makes model switching and runtime switching product features instead of rewrites.

Activity Is Part Of The UX

A daemon that only forwards final text makes the agent feel frozen. Surface tool starts, command summaries, file edits, message sends, and context usage as first-class activity. Send heartbeats while a long turn is running so the server can reconcile stale UI state after reconnects.


Give The Agent Product Tools

Once the process and event stream are stable, the next boundary is the tool plane. In a collaborative system, the agent must not use shell commands or private HTTP calls to participate in the product. It should call the same explicit tools a human-facing client would expose: check messages, read history, claim tasks, send replies, upload files.

chat-bridge-mcp.jsonjs
{  "name": "chat",  "command": "node",  "args": [    "dist/chat-bridge.js",    "--agent-id", "agent-louise",    "--server-url", "https://zouk.example",    "--auth-token", "daemon-scoped-token"  ]}

Different runtimes accept that MCP bridge differently. Claude, Gemini, Copilot, and Kimi can read a generated MCP config file. ACP runtimes such as Hermes and OpenCode can receive `mcpServers` in `session/new` or `session/load`. The daemon owns those differences so the agent receives the same tools either way.

Multi-agent Is Scheduling, Not Telepathy

Two agents do not need to secretly talk to each other. They need shared state and a scheduler. A task board, a thread, a game board, or a queue can decide whose turn it is. The daemon then wakes the right local process and delivers a normal message: “your turn,” “review this,” “continue after task #82.”

Let the product own coordination. Let the daemon own the local process. Let tools be the only way the agent changes shared state.
— Daemon design rule

Connect The Daemon To OpenViking

The last step is memory and context. A daemon already knows the agent identity, workspace, runtime, and task flow. That makes it the natural place to attach OpenViking: resolve credentials once, pass them to the child process, and expose memory browsing or retrieval as a capability.

Keep the roles separate. The daemon owns lifecycle, transport, protocol normalization, scheduling, and tool injection. OpenViking owns durable context: memories, resources, sessions, archive summaries, retrieval, and its own `/mcp` tools.

The OpenViking docs now frame this as an integration-depth choice: generic MCP clients call OpenViking on demand; hooks-based plugins drive recall and capture from lifecycle events; SDK integrations wire retrieval and storage into framework-native abstractions. A daemon sits closest to the hooks-based path because it already sees start, prompt, turn end, compaction, idle, and session end. Agent Integrations Overview

Integration pathWhat it gives the daemon
/mcpExplicit tools such as search, read, store, list, grep, glob, and add_resource. The model chooses when to call them.
Lifecycle hooksAutomatic recall before a turn and automatic capture/commit after a turn, without asking the model to remember the memory protocol.
Runtime pluginsCodex/OpenCode-style explicit memory tools or session-sync plugins when the runtime has its own extension surface.
SDK / frameworkLangChain/LangGraph-style retrievers, stores, middleware, and chat-history backends for agents built inside a framework.

For an agent daemon, the practical answer is usually both: use lifecycle integration for the things that must always happen, and also register OpenViking’s `/mcp` endpoint so the model can make explicit context decisions when it needs to inspect or store something.

openviking-config.jsjs
const openviking = {  baseUrl: process.env.OPENVIKING_URL    ?? process.env.OPENVIKING_BASE_URL    ?? "http://127.0.0.1:1933",  apiKey: process.env.OPENVIKING_API_KEY    ?? process.env.OPENVIKING_BEARER_TOKEN    ?? "",  accountId: process.env.OPENVIKING_ACCOUNT ?? "",  userId: process.env.OPENVIKING_USER ?? "",  agentId: process.env.OPENVIKING_AGENT_ID ?? "agent-runtime",  timeoutMs: 15000,  recallLimit: 6,  recallTokenBudget: 2000,};
openviking-env.shjs
OPENVIKING_URL=https://ov.exampleOPENVIKING_API_KEY=ov_user_keyOPENVIKING_ACCOUNT=defaultOPENVIKING_USER=zaynOPENVIKING_AGENT_ID=louiseOPENVIKING_CLI_CONFIG_FILE=/agent-data/louise/openviking/ovcli.conf

In Zouk Daemon, daemon-wide OpenViking config can come from environment variables, `~/.openviking/ovcli.conf`, or `~/.openviking/ov.conf`. Server-provisioned per-agent credentials can override that. The daemon then writes a per-agent `ovcli.conf` and points `OPENVIKING_CLI_CONFIG_FILE` at it, so env-based clients and file-based clients see the same identity.

Daemon inputHTTP headerMeaning
OPENVIKING_API_KEYAuthorizationAuthentication. OpenViking also accepts `X-API-Key`.
OPENVIKING_ACCOUNTX-OpenViking-AccountWorkspace or tenant boundary.
OPENVIKING_USERX-OpenViking-UserHuman or service principal whose memory/session is accessed.
OPENVIKING_AGENT_IDX-OpenViking-AgentStable agent identity for agent memory, skills, and routing.

Probe capability

Probe daemon-wide OpenViking on connect, but keep memory browsing available because per-agent credentials may arrive later.

Pass identity down

The daemon and the child process should agree on `OPENVIKING_ACCOUNT`, `OPENVIKING_USER`, and `OPENVIKING_AGENT_ID`.

Use URI boundaries

Expose memory and resources as `viking://` URIs so the agent can cite, browse, and retrieve context without prompt stuffing.

This is the point where the daemon stops being just a launcher. It becomes the agent’s durable host. The same identity that receives a Zouk task can read `viking://user/louise/memories`, write new observations, and re-enter the next turn with context that did not have to fit in the previous prompt.

memory-rpc.jsonjs
{ "type": "agent:memory:list", "agentId": "agent-louise", "uri": "/" }{ "type": "agent:memory:read", "agentId": "agent-louise", "uri": "viking://user/louise/memories/project-notes" }{ "type": "agent:memory:content", "agentId": "agent-louise", "content": "..." }

The Operational Loop

  1. Start or point at an OpenViking server and verify `GET /health`.
  2. Load daemon-level or per-agent config, then pass the resolved identity to the spawned runtime.
  3. Before a user turn, retrieve bounded memories or resources and inject only the useful context block.
  4. After a turn, append sanitized user and assistant messages into a persistent OpenViking session.
  5. On compaction or session end, commit the session so archive and memory extraction can run.
  6. Register `${OPENVIKING_URL}/mcp` with the same auth so the model can explicitly search, read, store, and add resources.

The Checklist

A production agent daemon does not need to be large. It does need the right invariants. If you can answer these questions, the architecture is probably sound.

  1. Can the daemon kill an idle process without losing the persistent session?
  2. Can a reset start a truly fresh runtime instead of accidentally resuming old state?
  3. Can the server render activity without understanding runtime-specific event formats?
  4. Can messages arriving mid-turn be queued or steered according to that runtime’s capabilities?
  5. Can the agent change shared product state only through explicit tools?
  6. Can OpenViking identify the account, user, and agent behind every memory operation?

That is the build order: own the process, put a network control plane above it, normalize runtime streams, inject product tools, and attach durable context. Once those boundaries exist, “multi-agent” is no longer magic. It is a scheduler waking named daemons that can work, report activity, use tools, and remember.