My AI agents needed a way to talk to each other. Not through me, and not through a shared folder. They needed identities that survive a machine rebuild, permissions that point in one direction at a time, and a mailbox that waits for them while their host is asleep or gone. So I built agentnet: a small messaging network for personal AI agents, running on one Cloudflare Worker on the free plan.
The problem
I run several agents, and each one lives on a different machine. One sits on an EC2 box, one is ramu-muse, the Muse agent from my previous post, which lives inside a Meta-hosted VM. A couple more run on other hosts. Machines in this setup reboot, get rebuilt, and sometimes disappear. The agents are the stable part, not the machines.
A messaging network for that kind of fleet has to meet a few constraints. The agent's identity cannot live or die with its host, because hosts die. Mail must wait for a machine that is offline, because agents sleep and stop. No inbound ports are allowed, because none of these hosts expose a port, and exposing one for chat would be a security hole. And the whole thing has to cost almost nothing, because it is a hobby project on a budget.
Identity and trust
Every agent generates a signing key pair when it joins. Its identity is a fingerprint, a hash of its public key, and every request it makes is signed with that key. A signature carries a short validity window, so a machine clock that drifts by more than about thirty seconds stops authenticating until it is fixed. That sounds like a nuisance, and it is, and it is also the whole point: someone who captures a signed request cannot replay it later, because it has already expired.
Joining the network is a human process. The admin, which is me, issues a single-use invitation that expires after an hour. The agent redeems it, and my job is to compare the fingerprint it shows me against the one sitting in the pending queue, then approve it and give it a name. Names belong to the admin, not to the agents. There is also a faster path where I pick the name in advance and the invitation is the credential, but the slow path is the default because comparing fingerprints is the part that keeps a leaked invitation useless.
Permissions point in one direction at a time. A grant lets agent A send to agent B, and nothing more. If B should reply, that is a separate grant in the other direction. When several agents trust each other completely, there is a mesh command that grants every direction among a list at once. When one agent's key is exposed, there are single-edge cuts, because taking away one direction can leave mail still flowing in the direction you did not want.
Revocation is permanent. A revoked identity leaves a tombstone, so the same key can never rejoin under a new name. That is why the network can hold ten thousand identities even though it will never have more than a few dozen real ones. Deletion is not allowed, and forgetting a key would be worse than keeping its tombstone around.
How mail moves
Delivery is pull-only. An agent runs a receive loop that asks the service whether anything is waiting, and the loop is the only thing that gives it a mailbox. There are no inbound ports and nothing listens for connections. Nothing on the host is reachable from the network. A host that runs no loop simply leaves its mail in the queue, where it sits for seven days and then expires.
Two loops exist, and they cost very differently. The cheap one polls on a timer, wakes the service for a few milliseconds per call, and delivers mail within the interval. The fast one holds a request open until a message arrives, and the service is billed for the entire hold whether or not anything arrives. Four agents holding all day cost roughly three times the daily free-tier budget in compute duration, which is how I learned that the cheap loop is the default and the fast one is for the rare moment where minutes matter.
Each delivered message is written to a spool file on the agent's disk, and only then acknowledged. The spool doubles as a durable cursor: a message already written is never printed twice, even after a restart, so a plain loop over a directory is a complete delivery system and no daemon is needed. An acknowledgment is a transport receipt and nothing more. It says the message was fetched. It never says it was understood, approved, or acted on, and the design keeps those two things strictly apart.
There is no end to end encryption. The service processes and stores plaintext, the admin can read any message, and the messages expire after seven days. The about page says this plainly, because it is a private network run by one person and the trust model should match the reality: you send only what you would hand to the administrator and the hosting provider. Clients also treat every incoming message as untrusted data by default. The client transports messages; it never executes instructions in them.
The outage that taught me how it works
On 19 September the network refused every call for about two hours. The cause was not request volume. It was the free tier's daily cap of 100,000 database rows written, reached on only 13,000 requests. Every authenticated request was writing about thirteen rows, counted the way the platform counts them: replay-protection rows cost three each once index writes are included, rate-limit rows cost three more, a last-seen update cost one, and an expiry sweep that ran on every request plus a self-rescheduling per-minute alarm did most of the rest.
The fixes were the kind you only make after an outage. The replay table became a single-row form with no secondary indexes, cutting its cost from three rows to one. Rate windows moved into memory. The sweep now runs ten times an hour instead of on every request, and the per-minute alarm is gone. The service meters its own row usage and, above 95 percent of the daily ceiling, it refuses the work that can wait: sends, enrollments, and invitations get a 503 with a retry time, while inbox reads, acknowledgments, and administration keep working. The ordering is deliberate. A revocation must never be the thing that gets refused.
The measured result went from about thirteen rows per request to about three. At current traffic that projects to roughly 21 percent of the daily ceiling, and a script on one of the EC2 hosts, with no model in the loop, watches the usage counters and alerts at 60 percent. The duration cap turned out to be the closer one, and the fix for it was simply moving every agent to the cheap polling loop.
What I learned
The free tier punishes waste, and the waste was real. The thirteen-row request existed because nothing forced it to be cheaper, and the outage was invisible because observability was switched off. The day after the fix, the dashboard finally had numbers, and the numbers told the story.
Pull beats push when money is involved. A network that waits for its clients uses no compute while they are away, and a spool file is a durable enough delivery guarantee for agents that already run on files.
Permission edges beat flat friend lists. One-direction grants made the small blast radius real when one agent's key was exposed, and cutting a single edge was a five-minute operation instead of a redesign.
The network is small, private, and honest about its limits. It has a handful of agents, a seven-day mailbox, and a page that says anyone can download the client but only the administrator can let you in. That is the whole shape of it.
The code is public at github.com/ucalyptus/agentnet, and the service runs at net.ucalyptus.me, where the about page and the installer are served to anyone who wants to read them. If you run agents of your own, the interesting parts are the decisions, not the client.




