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
| Part | Model | Where |
|---|---|---|
| Main session | Claude Opus 5.5 | Claude Code on my laptop |
| Clotho, gathers | Claude Opus 5.5 | Background subagent of that session |
| Lachesis, decides | Claude Opus 5.5 | Background subagent of that session |
| Atropos, reviews | Claude Opus 5.5 | Background subagent of that session |
| Local models | None | Not 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:
{
"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.
| File | Owner | Content |
|---|---|---|
evidence.md | Clotho | Raw facts for each address, each with the command that gave it |
identities.md | Lachesis | One row for each address: device, confidence, evidence used |
final.md | Atropos | The accepted list, the open list, and the reasons |
messages.log | All | One 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:
- 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:
# 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
| Time | From | To | Message | What the receiver did |
|---|---|---|---|---|
| 15:51:05 | Clotho | Lachesis | The evidence is ready, and it is thin for five addresses | Made draft decisions |
| 15:51:38 | Lachesis | Clotho | Send the gateway rows for those five | Made one GET of the gateway page |
| 15:52:40 | Clotho | Lachesis | The facts are in the file | Moved one address from low to medium, one from medium to high |
| 15:53:48 | Lachesis | Atropos | The list is ready: 6 high, 2 medium, 2 low | Checked ten rows, rejected three |
| 15:54:48 | Atropos | Lachesis | Three rows are rejected, with reasons | Downgraded 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:
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
| Agent | Tokens | Tool calls | Active time |
|---|---|---|---|
| Clotho | 100,752 | 21 | 463 s |
| Lachesis | 70,212 | 16 | 252 s |
| Atropos | 72,940 | 10 | 147 s |
| Total | 243,904 | 47 | About 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:
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.
| Part | Program | Model | Where |
|---|---|---|---|
| Clotho, the server | Pi 1.1.0 | qwen3.5:9b | Its own VM on the sandbox network |
| Lachesis, the page | Pi 1.1.0 | qwen3.5:9b | Its own VM on the sandbox network |
| Atropos, the test | Pi 1.1.0 | qwen3.5:9b | Its own VM on the sandbox network |
| Model server | Ollama 0.40.3 | A gaming PC with an RTX 4080 | |
| Inbox | Hermes | Its own VM on the sandbox network | |
| Cloud models | None |
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 one | Rule in Hermes |
|---|---|
| The agents wrote the log, each a little differently | The service writes each record |
| No proof of the sender | Each agent has a token, and the service sets the sender from it |
| "Acted on" was free text | acted is its own record type, and it must point at a message that was sent to the caller |
| Ids, not names | Names exist before the start, in the config of the service |
| Hard to show | A web page streams each record as it arrives |
One table holds the log:
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:
| Call | Use |
|---|---|
POST /api/send | Send a message. A message to yourself is refused |
GET /api/inbox?wait=50 | New messages for the caller, each one time. The call waits for the first one |
POST /api/acted | Record what the caller changed because of a message |
PUT /api/files/NAME | Publish a shared file. The first agent that publishes a name owns it |
GET /api/log, GET /events | The 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:
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:
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:
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:
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:
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:
{
"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:
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-idwith a fixed id gives the agent its memory across starts. Each start is a new process, and the session file is the context.< /dev/nullis not decoration. With an open stdin and no terminal,pi -pwaits 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_WAKESandtimeoutare 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:
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:
| Id | Time | Record |
|---|---|---|
| 1, 2 | 18:00:51 | The owner sends the job to Clotho and to Lachesis |
| 3 | 18:01:10 | Clotho sends Lachesis the contract |
| 4 | 18:01:33 | Lachesis publishes index.html, version 1 |
| 5 | 18:01:35 | Lachesis tells Clotho: published, with the field names it used |
| 6 | 18:02:13 | Clotho publishes server.py, version 1 |
| 7 | 18:02:23 | Clotho tells Atropos: ready for test |
| 8 | 18:02:47 | Atropos 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:
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:
$ 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 one | Run two | |
|---|---|---|
| Job | Identify ten network devices | Build a small web app |
| Model | Claude Opus 5.5, cloud | qwen3.5:9b, on my own GPU |
| Agent program | Claude Code subagents | Pi 1.1.0 |
| Where the agents ran | My laptop | One sandbox VM each |
| Messages | Built-in, agent to agent | Hermes |
| Who wrote the log | The agents | The service |
| Wall clock | About 12 minutes | 1 minute 49 seconds |
| Cloud tokens | 243,904 | 0 |
| A message that changed the receiver | Two: a request for evidence and a rejection | One: the contract |
| A disagreement | Yes, three rows rejected | No |
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
actedrecord. 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.