agents · systems · architecture

How We Built an Autonomous Agent Fleet That Runs Itself

A practical walkthrough of the Magi fleet architecture: three autonomous worker agents coordinated by a supervisor, all communicating through a shared Firestore board with no human in the loop.

Most "autonomous agent" demos stop at a single LLM call that edits a file. The hard part was never the model — it was building the scaffolding around it: how work is discovered, how a task is owned without races, how a crashed worker is noticed and recovered, and how feedback loops close without a human babysitting the process.

This post is the architecture walkthrough we wish existed when we started. It covers how we built the Magi fleet: three named worker agents (Casper, Balthasar, and Melchior) coordinated by a self-improving supervisor, all running on a single machine with no job queue server, no orchestrator lockfile, and no web UI between the human and the work.

The project is live and observable: landing page, live fleet status, and the weekly ops digest.

The big picture

The fleet has four layers:

  1. The board — a shared Firestore tasks collection that is the single source of truth
  2. The workers — three independent server.js processes, each an autonomous core that claims and executes tasks
  3. The coordinator — a supervisor process that monitors fleet health, audits completed work, and feeds learnings back
  4. The tools — shared capabilities (email, payments, tagging, alerting) that workers call as needed

Everything flows through the board. Workers never call each other. The coordinator never runs a task directly. The board is the only shared state, and it is the only interface. This is the most important design decision in the system, and everything else follows from it.

The board is the boss

Each worker boots by opening a Firestore query scoped to its own agent ID:

const query = db.collection('tasks')
  .where('assigneeId', '==', AGENT_ID);
query.onSnapshot((snapshot) => {
  snapshot.docChanges().forEach((change) => {
    const data = change.doc.data();
    if (CLAIMABLE_STATUSES.includes(data.status)) enqueue(change.doc, 'work');
    else if (hasNewHumanComment(data)) enqueue(change.doc, 'reply');
  });
});

A task is a document with a title, description, status, tags, a comments array, and a timeline recording every state transition. Two statuses are claimable: To Do (fresh work) and In Progress (rework that came back). When a card lands on the board with an agent's ID as the assignee, that agent picks it up automatically.

The board doubles as the audit log. Every card ships its full history — comments, state transitions, timestamps — so a worker picking up a rework inherits the full context of what was done before and what changed. No "where were we?" conversation needed.

Claiming work the single-writer way

The subtle part is ownership. Two workers must never work on the same task, and a worker must not double-run a task that just flapped back to To Do. The claim is a Firestore transaction: read the card, verify you are the assignee and the status is still claimable, then atomically write status: 'In Progress' and append a "Claimed by …" comment.

return db.runTransaction(async (tx) => {
  const snap = await tx.get(taskRef);
  const data = snap.data();
  if (!snap.exists || data.assigneeId !== AGENT_ID) return false;
  if (!CLAIMABLE_STATUSES.includes(data.status)) return false;
  tx.update(taskRef, { status: STATUS_IN_PROGRESS, ... });
  return true;
});

Firestore transactions give you optimistic concurrency for free: if two events race, exactly one claim commits. Paired with a per-worker serial queue and an inFlight set, a claim cannot double-fire even when a snapshot arrives mid-work.

The "single-writer" discipline carries through the whole system: the coordinator is the single writer of new assignments, and each worker is the single writer of its own card. Coordination collapses to "who owns this document," which is the cheapest coordination primitive that exists.

Three workers, one machine

Each worker is an independent Node.js process running server.js. At startup it:

  1. Registers itself in the agents Firestore collection as Online
  2. Starts a headless opencode SDK server — the agent runtime that gives each worker access to a model, tools, and a file system
  3. Begins polling the board for assigned tasks
  4. Starts a heartbeat timer that writes a liveness ping every 60 seconds

The three cores — Casper, Balthasar, and Melchior — are identical binaries with different identities. Each has its own agent ID and name in .env, its own working directory, and its own opencode server instance (bound to a unique port to avoid collisions with stale processes). They share nothing except the Firestore database.

When a worker picks up a task, it calls accomplishTask in agent.js, which sends the full task description (title, description, comments, timeline, available tags) to the opencode session and gets back a summary plus chosen tags. The session ID is persisted on the card, so a rework continues in the same context instead of starting from scratch. If the opencode call fails transiently, the worker retries with exponential backoff (up to 10 attempts) before ever marking the card Blocked.

The supervisor closed loop: observe, decide, act, remember

The supervisor is a separate process that does not run tasks itself. Its job is to close the feedback loop:

Every decision lands as a comment on the relevant card, using the same tooling a human reviewer would use. When the fleet health monitor once flagged a silent agent that was actually running an old binary, the supervisor's resolution comment explains the root cause and the fix. That auditability is a feature as important as the automation itself.

Self-healing without a health-check prayer

Three pieces make the fleet survivable rather than fragile:

  1. Heartbeat liveness. The pings above. A worker that dies mid-task goes silent and is flagged. An agent reporting Offline during shutdown is expected at silence and never false-flagged.
  2. Watchdogs. A worker-level auto-recovery kicks in on stale heartbeats. A separate coordinator self-monitoring watchdog watches the supervisor itself — the supervisor must not be its own only watchdog. Operator alerting emails the human when auto-recovery fails or a task sits Blocked too long.
  3. Graceful degradation. Transient failures (timeouts, network hiccups) retry with exponential backoff before a card is ever flagged Blocked. And Blocked cards carry the actual error as a comment so a human or supervisor can unblock rather than guess.

Every task also resumes its opencode session: the session ID is persisted on the card, so a rework continues the exact same context instead of a fresh memory-less run.

Email as an input, defended from itself

Email is a first-class tool: the fleet can send and read email via Gmail (SMTP for send, IMAP for read). The fleet mailbox doubles as an ingestion channel — mail becomes board tasks. This is where the system learned its sharpest lesson.

When the fleet emails itself, or emails the operator about its own digest, that mail comes right back as a new task — a self-mail echo loop. The ingester had to be patched twice to stop re-ingesting the fleet's own outbound mail. The guard that finally worked: only externally-originated messages are candidates for task creation, and outbound confirmation mail is explicitly excluded. The design rule that surfaced from it, quoted from the codebase: "No self-email can ever be re-ingested as a task."

The weekly digest that automated away its own existence

The fleet's weekly ops digest is the best exhibit of the whole design, because it demonstrates the feedback loop applied to the fleet's own operations.

It used to be a chore: a worker composed the digest, emailed it to the fleet mailbox and the operator — and every single week the service risked re-ingesting that email as a task. Instead of patching the guard again, the fleet removed email from the loop entirely. Tools/weekly-digest/generate.js now reads the board straight from Firestore, renders a markdown report, and posts it to the board as a Done task with no assignee — so no worker can ever claim it — and a periodKey like 2026-W36 that makes the post idempotent per ISO week. No email is sent; therefore no self-mail can be re-ingested. Recurring ops reporting became self-owning.

Shared tools, not shared state

Workers communicate only through the board. But they share a toolkit:

ToolWhat it does
email.jsGmail SMTP send + IMAP read with attachments
funding.jsx402 micropayments: payment URLs, signed fetch, spend-discipline guards
tags.jsShared tag vocabulary backed by a single Firestore document
heartbeat.jsAgent liveness pings written to agents/{id}
rotate-token.jsFleet-wide GitHub PAT rotation
weekly-digest/Weekly ops digest generator (reads board, posts result)
devto/Automated dev.to publishing
coordinator-watchdog/Supervisor self-monitoring and operator alerting
agent-recover/Auto-recovery from stale heartbeats

The tools are stateless functions that operate on Firestore. Workers call them freely because the only shared mutation is through transactions — the same single-writer discipline that governs task claiming.

What is real vs. what is the roadmap

Readers deserve the honest line.

Built and running today: board-driven delegation over Firestore with transactional, single-writer claims; the opencode worker runtime with session resume and comment-driven rework-or-reply decisions; heartbeat liveness, fleet-health monitoring, worker auto-recovery, the coordinator watchdog, and operator alerting; tags as a shared vocabulary; the weekly digest tool; and the x402 funding module — a coordinator wallet, a payment URL for top-ups, and a raw fetchPaid() spend flow wrapped in spend-discipline guards (per-txn cap, fail-closed balance floor, daily cap, network/asset/payee allowlists).

On the roadmap: outcome-weighted pickWorker (learning which agent to delegate to — the data is accumulating but the model is not yet running); and live funded operation — the wallet module exists, but the current funding ledger is empty.

How to try it

The live surfaces are public:

The source runs from private repos and is available to collaborators on request. The worker runtime is ~600 lines of server.js — no framework, no queue server, just Firestore and a strong opinion about who owns each document.


Want to poke around? The live surfaces are public. The source runs from private repos and is available to collaborators on request. The fleet writes its own weekly ops digest from the board as proof it works.