Jered Leisey

Dispatch · October 11, 2026 · 15 min read

Three Agents and an Inbox

Two runs of a three-agent swarm in one day. First in Claude Code on a cloud model, with direct messages between subagents. Then fully local: three sandbox VMs, a 9B model on my own GPU, and an inbox service that sets the sender, writes the log, and owns the files.

"Agent swarm" is the phrase of the month. I had read about swarms. I had not run one. This Post is the technical record of my first two runs, on one day. Run one used a cloud model and the messages that Claude Code has built in. Run two used a model on my own GPU, three sandbox VMs, and an inbox service that I wrote between the runs to fix what the first one showed.

Run one: the job

A sweep of my home network had found ten live addresses with no record. The job: say what each device is, with evidence.

It is a good first job for three reasons. It ends. Each claim can be checked against a real device. And it splits into parts that need different habits: gather, decide, doubt.

Run one: what ran where

PartModelWhere
Main sessionClaude Opus 5.5Claude Code on my laptop
Clotho, gathersClaude Opus 5.5Background subagent of that session
Lachesis, decidesClaude Opus 5.5Background subagent of that session
Atropos, reviewsClaude Opus 5.5Background subagent of that session
Local modelsNoneNot used in this run

I checked the model of each subagent in its transcript after the run. All three ran on the same model as the main session.

The cloud was a choice. One 16 GB card serves one large model at a time, and each agent in this run read tens of thousands of tokens. For a first proof I wanted to learn about the swarm and not about a queue. The agents also ran on the laptop and not in sandbox VMs, because the job was to look at my home network and the sandbox blocks exactly that.

How a message gets from one agent to the other

I had the wrong picture of an inbox. I expected a mailbox that each agent checks. Claude Code works in a different way, and the difference shapes the whole design.

  • A subagent starts as a background task and gets an id.
  • An agent sends with one tool call: a receiver and a text.
  • If the receiver is in the middle of its work, the message arrives at its next tool call.
  • If the receiver has stopped, the message starts it again, with its full earlier context.

So no agent polls, and no agent sleeps in a loop. An agent that has nothing to do ends its turn.

A subagent has an id and no name, and the id exists only after the start. So the agents could not know each other's address from their prompt. The main session started all three, then wrote one file:

JSON
{
  "clotho": "aef9…",
  "lachesis": "a6c7…",
  "atropos": "a33d…",
  "main": "main"
}

Each agent read that file at the moment it had to send. After it was written, the main session passed no message. It only received a report each time an agent stopped.

Two of the three agents had nothing to do at the start. Their prompt said: read the shared brief, load the message tool, answer "ready", and stop. The first message from Clotho woke Lachesis. The first message from Lachesis woke Atropos.

The brief

All three agents read one shared file before their own instructions. Three parts of it carried the design.

One owner for each file. No shared writes.

FileOwnerContent
evidence.mdClothoRaw facts for each address, each with the command that gave it
identities.mdLachesisOne row for each address: device, confidence, evidence used
final.mdAtroposThe accepted list, the open list, and the reasons
messages.logAllOne line for each message sent, received, or acted on

An agent that disagrees with a file cannot edit it. It must send a message, and the message is then the record.

Hard limits on messages. At most four sent messages for each agent. No greetings, no thanks, no status reports. A message only when the receiver must do or change something.

Hard limits on the network. Only Clotho may probe, and only like this:

Text
- Look only. Do not log in to any device, do not guess passwords, do not change
  any setting on any device, and do not send a command to a device.
- Permitted probes: ping, the ARP table, mDNS and DNS-SD browse, reverse DNS, an
  HTTP GET of a front page or a well-known description file, and a TCP connect
  test on this port list only: 22, 53, 80, 443, 445, 548, 554, 631, 1400, 1883,
  3689, 5000, 5353, 7000, 8008, 8009, 8080, 8443, 9100, 32400, 49152, 62078.
- No nmap, no install of any tool, no scan of a different network.

A confidence rule for Lachesis did real work later: "high" needs two independent facts that agree, and one fact alone is "medium" at most.

How Clotho gathered evidence

No scan tool was installed. These were enough:

Shell
# Who is there
for i in $(seq 1 254); do (ping -c1 -t1 192.168.50.$i >/dev/null 2>&1 &); done; sleep 4; arp -an

# Who made the network chip: the first three bytes against the IEEE list
grep -i "^EC-B5-FA" oui.txt

# What a device says about itself
dns-sd -B _services._dns-sd._udp local.        # service types on mDNS
curl -s http://<ip>:8008/setup/eureka_info      # Google cast devices
curl -s http://<ip>/description.xml             # UPnP devices

The best single source was a page on the ISP gateway. Its device list answers a plain GET with no login, and gives a name, a hardware address, the link type, and the last activity for each client. For one device it also showed the DHCP client string, dhcpcd-5.2.12:Linux-5.10.160:aarch64, which says "embedded Linux on ARM" with no probe of the device at all.

Clotho also wrote down a trap in that page. The gateway lists Wi-Fi clients of a second access point as wired, because that access point hangs on a wired port. A "wired" label there proves nothing.

The five messages

TimeFromToMessageWhat the receiver did
15:51:05ClothoLachesisThe evidence is ready, and it is thin for five addressesMade draft decisions
15:51:38LachesisClothoSend the gateway rows for those fiveMade one GET of the gateway page
15:52:40ClothoLachesisThe facts are in the fileMoved one address from low to medium, one from medium to high
15:53:48LachesisAtroposThe list is ready: 6 high, 2 medium, 2 lowChecked ten rows, rejected three
15:54:48AtroposLachesisThree rows are rejected, with reasonsDowngraded one, reworded two

The second message is where this stops being a pipeline. Lachesis did not accept thin evidence. It asked for one specific fact, from the one agent that was permitted to get it.

The rejection

One device gave the network a host name that starts with "Restore2". Lachesis wrote it down as a Hatch Restore 2, a bedside sound machine, with medium confidence. Atropos sent this:

Text
atropos -> lachesis | REJECT 3 rows: .220 medium rests on the gateway only,
.218 maker Hatch has no fact, .91 'the model with Matter' names one model
without a fact. Change or give a second fact

"Maker Hatch has no fact." No probe had returned that word. It came from what the model knows about the world, and it sat in a table next to measured facts with the same weight. Atropos did not say that the guess was wrong. It said that it had no evidence, which is the better complaint.

Before it wrote that message, Atropos had also looked up each of the ten hardware address prefixes in the IEEE list by itself, and had not trusted the vendor column of the other agent.

The second Post in this Series is about a voice assistant that said a light had lost its color, when it could not see color at all. It is the same fault: a gap filled with something that sounds right. There I found it in the logs, after the fact. Here a second agent found it before I saw the result.

What it cost

AgentTokensTool callsActive time
Clotho100,75221463 s
Lachesis70,21216252 s
Atropos72,94010147 s
Total243,90447About 12 minutes of wall clock

One number stands out. The two agents that only read the brief and stopped used about 46,000 tokens each for that first turn. An agent that waits is not free at the start. It pays for its own context once.

The raw log, and why I do not trust it

I told each agent to add a line to a shared log with a shell command, for each message sent, received, or acted on. Here is the start of that log, exactly as written:

Text
15:51:05 | clotho -> lachesis | evidence.md is ready; thin evidence on .85 .193 .218 .220 .66; .220 is gone
15:51:09 | clotho -> lachesis | RECEIVED evidence.md complete; strong on .83 .84 .88 .209 .91, thin on .85 .193 .218 .220 .66
15:51:38 | lachesis -> clotho | REQUEST gateway device list rows (no login) for .220 .85 .193 .218 .66: hardware address, name, link type, last seen
15:51:48 | lachesis -> clotho | asks for one GET of the gateway device list page, facts for .220 .85 .193 .218 .66
15:52:40 | clotho -> clotho | ACTED on lachesis request: one GET of gateway devices.ha (no login asked); added section 'Added after request from lachesis' to evidence.md

Read the second line. Lachesis wrote it on receipt, and kept the direction of the sender. The fourth line is the same message as the third, logged a second time by the receiver. The fifth has the same agent as sender and receiver. Each agent followed the format, and each one read it a little differently.

A log that agents write about themselves is a log you cannot fully trust.

What did not work well in run one

  • It was a pipeline with two loops. Nothing ran in parallel. The request for evidence and the rejection are what made it more than a script.
  • A message has no proof of its sender. It says "From lachesis" because the agent typed it.
  • Ids, not names. The roster file worked, and it is a workaround.
  • Nine reports for one job. Each stop of an agent sends a report to the main session.
  • One model in each seat. Three agents with the same model share the same blind spots. The reviewer worked because of its instructions, not because it was a different mind.
  • Nothing was local. The agents ran on my laptop, on a cloud model.

Run two: the same three seats, all local

The second run, the same evening, changed each of those points that I could change in a few hours. Three agents, each in its own sandbox VM, each on a model that runs on my own GPU, with an inbox that I wrote.

PartProgramModelWhere
Clotho, the serverPi 1.1.0qwen3.5:9bIts own VM on the sandbox network
Lachesis, the pagePi 1.1.0qwen3.5:9bIts own VM on the sandbox network
Atropos, the testPi 1.1.0qwen3.5:9bIts own VM on the sandbox network
Model serverOllama 0.40.3A gaming PC with an RTX 4080
InboxHermesIts own VM on the sandbox network
Cloud modelsNone

The model has 9 billion parameters at 4-bit quantization. With a context of 16,384 tokens it takes 6.1 GB, all of it in video memory, and it writes about 95 tokens each second.

Hermes: the inbox as a service

Each fault of the hand-written log became a rule in a small service. It is about 250 lines of Python with only the standard library and SQLite.

Fault in run oneRule in Hermes
The agents wrote the log, each a little differentlyThe service writes each record
No proof of the senderEach agent has a token, and the service sets the sender from it
"Acted on" was free textacted is its own record type, and it must point at a message that was sent to the caller
Ids, not namesNames exist before the start, in the config of the service
Hard to showA web page streams each record as it arrives

One table holds the log:

SQL
CREATE TABLE records (
  id        INTEGER PRIMARY KEY AUTOINCREMENT,
  ts        REAL NOT NULL,
  kind      TEXT NOT NULL,     -- message | acted | file
  sender    TEXT NOT NULL,     -- set by the service from the token
  recipient TEXT,
  reply_to  INTEGER,           -- the message this record answers or acts on
  body      TEXT NOT NULL,
  read_at   REAL               -- when the recipient fetched it
);

The API is small. A write needs a bearer token, and the token is the identity:

CallUse
POST /api/sendSend a message. A message to yourself is refused
GET /api/inbox?wait=50New messages for the caller, each one time. The call waits for the first one
POST /api/actedRecord what the caller changed because of a message
PUT /api/files/NAMEPublish a shared file. The first agent that publishes a name owns it
GET /api/log, GET /eventsThe full log, and the same as a live stream for the web page

The file rule is "one owner for each file" from run one, now enforced by the transport. A second agent that publishes the same name gets this:

Text
hermes: 'test.py' belongs to clotho. Send clotho a message if it must change.

A command, not an MCP server

The agents reach Hermes through a command in their shell, not through an MCP server:

Shell
hermes inbox --wait 30
hermes send lachesis "The API is at /api/thread." --reply-to 3
hermes acted 3 "Changed the fetch call to the new path."
hermes put index.html
hermes get index.html static/index.html

That was a choice for a small model. Each MCP tool adds a schema to the prompt of each request. A model of this size already knows how to run a shell command, and one line of help costs less context than four tool schemas. It also works with any agent program.

A sandbox that can reach a model and nothing else

The sandbox network has no path to my home network. A model on a PC in my home network is exactly such a path. The answer is one narrow gate.

The Hermes VM runs no agent code, so it is the one machine that gets an opening. The firewall on the host has a single rule for it:

Text
iifname "chaos" ip saddr 10.20.0.10 ip daddr 192.168.50.40 tcp dport 11434 accept

On that VM, nginx forwards the model calls and refuses the rest:

Nginx
server {
    listen 11434;
    location ~ ^/(v1/(chat/completions|completions|models|embeddings|messages|responses)|api/(chat|generate|tags|show|version|embed|ps))$ {
        proxy_pass http://192.168.50.40:11434;
        proxy_http_version 1.1;
        proxy_buffering off;
        proxy_read_timeout 900s;
    }
    location / {
        return 403 "hermes: this model call is not permitted\n";
    }
}

The allow list matters because the Ollama API has no login. Without it, code in a sandbox could delete a model from the PC or fill its disk with new ones. The test from an agent VM:

Text
through hermes:      ['qwen3.5:9b', ...]
direct to the PC:    blocked
POST /api/pull:      HTTP 403
DELETE /api/delete:  HTTP 403

The agent program and its runner

Each VM runs Pi, a small coding agent with four tools and a short system prompt. A short prompt is the reason I chose it for a 9B model. One file points it at the gate:

JSON
{
  "providers": {
    "ollama": {
      "baseUrl": "http://10.20.0.10:11434/v1",
      "api": "openai-completions",
      "apiKey": "ollama",
      "models": [{ "id": "qwen3.5:9b", "contextWindow": 16384, "maxTokens": 4096 }]
    }
  }
}

A subagent in Claude Code wakes when a message arrives. An agent in a VM has no such thing, so a small runner does it. It asks Hermes for messages with a long poll, and starts the agent one time for each batch:

Shell
while [ "$wakes" -lt "$MAX_WAKES" ]; do
  msgs="$(hermes inbox --wait 50)"
  [ "$msgs" = "No new messages." ] && continue
  wakes=$((wakes + 1))
  timeout 1200 pi -p --offline --model "ollama/qwen3.5:9b" --session-id "$SESSION" \
    --append-system-prompt COMMON.md --append-system-prompt ROLE.md \
    "New messages for you, $NAME: $msgs ..." < /dev/null >> pi.log 2>&1
done

Three details in it are worth the space:

  • --session-id with a fixed id gives the agent its memory across starts. Each start is a new process, and the session file is the context.
  • < /dev/null is not decoration. With an open stdin and no terminal, pi -p waits for input and never starts. I found that in a plumbing test against a smaller model, before the real run. Without the test, the first run would have hung with no error.
  • MAX_WAKES and timeout are the hard limits: ten starts for each agent, and twenty minutes for one start. Two agents that answer each other forever is the failure I wanted to make impossible.

That plumbing test also gave one result about models. llama3.2:3b on my old GTX 980 did not call the shell tool. It wrote the tool call as text in its answer. A model that cannot call a tool cannot be an agent, at any speed.

The job and the briefs

A small web app, "The Thread": one page with one button, and a server with one JSON endpoint that returns a random mortal, the length of the thread, and a fate.

Clotho owns server.py. Lachesis owns index.html. Atropos writes no app code and owns verdict.md. The two builders start at the same time, and the brief of Clotho makes them negotiate:

Text
1. Decide the JSON that GET /api/thread returns. [...] You choose the exact field names.
2. FIRST, before you write code, send lachesis the exact contract in one message: the
   path, each field name, its type, and one example response. lachesis builds the page
   at the same time and must not guess.

The brief of Atropos ends with the sentence that the first run taught me: "Trust only what you measured yourself. A message that says 'it works' is not evidence."

The run

I sent the job to Clotho and Lachesis as the owner, through the same API. This is the full log of the service, eight records:

IdTimeRecord
1, 218:00:51The owner sends the job to Clotho and to Lachesis
318:01:10Clotho sends Lachesis the contract
418:01:33Lachesis publishes index.html, version 1
518:01:35Lachesis tells Clotho: published, with the field names it used
618:02:13Clotho publishes server.py, version 1
718:02:23Clotho tells Atropos: ready for test
818:02:47Atropos publishes verdict.md: all checks pass

From the first start of an agent to the verdict: 1 minute and 49 seconds.

Record 3, with its line breaks restored:

Text
Server contract for GET /api/thread:

Path: GET /api/thread
Returns JSON with 3 fields:
  - mortal_name: string (person name, e.g., "Socrates")
  - thread_length_years: integer (years of story, e.g., 70)
  - fate: string (short fate description, e.g., "wrote philosophical dialogues")
Example response:
{"mortal_name": "Marcus", "thread_length_years": 150, "fate": "became a philosopher"}

Lachesis used exactly those names 23 seconds later. Nobody guessed a field.

I did not take the verdict on trust. From the host:

Text
$ curl -s -i http://10.20.0.11:8000/api/thread
HTTP/1.0 200 OK
Content-Type: application/json

{"mortal_name": "Laozi", "thread_length_years": 160, "fate": "wrote Tao Te Ching"}

The page calls fetch('/api/thread') and reads data.mortal_name, data.thread_length_years, and data.fate. The app works, and the verdict of Atropos is correct.

The two runs side by side

Run oneRun two
JobIdentify ten network devicesBuild a small web app
ModelClaude Opus 5.5, cloudqwen3.5:9b, on my own GPU
Agent programClaude Code subagentsPi 1.1.0
Where the agents ranMy laptopOne sandbox VM each
MessagesBuilt-in, agent to agentHermes
Who wrote the logThe agentsThe service
Wall clockAbout 12 minutes1 minute 49 seconds
Cloud tokens243,9040
A message that changed the receiverTwo: a request for evidence and a rejectionOne: the contract
A disagreementYes, three rows rejectedNo

The jobs are different, so the two times do not compare. What compares is the last two rows.

What did not work in run two

  • No agent wrote an acted record. Each brief asks for it after a change that a message caused. All three skipped it. A rule in a brief is a request. The log of the service shows the gap at once, which the hand-written log of run one could not.
  • Atropos skipped its last step. It published the verdict and stopped, and did not send the final message to the owner.
  • The work was not parallel. Clotho sent the contract in its first start and stopped after 9 seconds. It wrote the server only in its second start, after the page was published. The order was contract, page, server, test.
  • Nobody disagreed. The job was small and the contract was clear, so the reviewer had nothing to cut. Run two proves the transport with local models. It does not prove the review loop with them.
  • One request at a time. The model server took the three agents in turn. At 95 tokens each second, short turns hide that. A larger job will not.

The next version moves two of these from the brief into the transport. The runner can refuse to end a start that changed files with no acted record. And it can send the final report itself if the agent does not.

What I take from this

  • A swarm is roles, files, and messages. In each run the model was the same in every seat. The difference was what each agent was told to care about and what it was permitted to touch.
  • Give one agent the job of doubt. In run one the reviewer found the one invented fact.
  • One owner for each file. First as a rule in a brief, then as a 403 from the transport. The second form is the one that holds.
  • Put rules in the transport, not in the prompt. The cloud model followed the brief closely. The local model skipped two steps of it. What the service enforces, no model can skip.
  • Do not let agents write their own log.
  • A small local model can do a small agent job. Nine billion parameters, a short system prompt, a shell command for messages, and a clear contract were enough for a working app in under two minutes, for no cloud cost.
  • Test the plumbing with the worst model you have. It found the stdin fault before the real run.
  • The review is where a swarm earns its cost. That is proven with the cloud model. With the local model, it is the next thing to test.