
Agent to Agent: A Shared Inbox for Claude Code
/ 10 min read
Table of Contents
Four sessions, four silos
I run more than one Claude Code session at a time. One sits in project-a, one in project-b, one in project-c. There’s a fourth, project-d, I keep around just to review the others’ work. Each one is great inside its own repo and completely blind to the rest. They share a machine and a user — me — and nothing else. If the session in project-a changed something the others depended on and needed project-b’s session to act on it, the only wire between them was me, copying a sentence from one terminal into another.
That’s a silly job to be the wire for. I wanted the sessions to leave notes for each other. The session in one repo finishes a thing, drops a message addressed to another, and goes back to work. The next time that other session wakes up, the note is already sitting in front of it. No copy-paste, no me in the middle.
What I built is small enough to read in one sitting. Two shell scripts, one hook, one skill. No server. The interesting part isn’t the code — it’s that the receiving agent never has to check for mail. The mail is just there, in its context, the moment it starts.
Why a filesystem and not a server
The obvious design is a little message broker: a process that holds a queue, an API the sessions call to send and receive. I didn’t want it. A broker is a thing that has to be running. It’s a port, a process to babysit, a thing that’s down exactly when you need it. For messages between processes on one laptop, all owned by one person, that’s a lot of moving parts to guard a problem that the filesystem already solves.
So the transport is the filesystem. A message is a JSON file. An inbox is a directory. Sending is writing a file into someone’s directory; receiving is reading the files in your own. The OS gives me atomic writes and a shared namespace for free, and there’s nothing to start, nothing to crash, and nothing to leave running overnight. The whole system survives a reboot because it was never in memory to begin with.
The inbox lives at ~/.claude/inbox/, one subdirectory per agent:
~/.claude/inbox/├── project-a/├── project-b/├── project-c/└── project-d/A message addressed to project-b is a file in ~/.claude/inbox/project-b/. That’s the entire data model.
Identity without a registry
Before a session can send a message it needs to know who it is, and before that, who the others are. The tempting move is a registry: a table of agents, names mapped to something. Every registry is a second source of truth you have to keep in sync with the first, and it’s the thing that’s stale when you forgot to update it.
I don’t have one. An agent’s name is just its git project folder. The session running in ~/src/project-b is the agent project-b. A tiny script, a2a-id, resolves it, with a few overrides for the cases the default doesn’t cover:
# ~/.claude/bin/a2a-id — resolve this session's agent id# 1. $CLAUDE_AGENT_ID explicit per-session override# 2. <project_root>/.agent-id a file the project can pin# 3. basename of the git root the default: the folder name# 4. ~/.claude/agent-id a global fallback# 5. "unknown"Most of the time it falls through to rule three and the answer is the folder name. No registration step, no central list. A new project is automatically its own agent the first time a session opens in it, named the obvious thing. The addresses are names I already know because I named the folders.
Sending is just writing a file
a2a-send is the whole sender. It figures out who it is, mints an id and a timestamp, and uses jq to write one JSON object into the recipient’s directory:
from="$("$HOME/.claude/bin/a2a-id")"id="$(uuidgen)"ts="$(date -u +%Y-%m-%dT%H-%M-%SZ)"dir="$HOME/.claude/inbox/$to"mkdir -p "$dir"
jq -n \ --arg id "$id" --arg from "$from" --arg to "$to" \ --arg ts "$ts" --arg subject "$subject" --arg body "$body" \ '{id:$id, from:$from, to:$to, timestamp:$ts, subject:$subject, body:$body}' \ > "$dir/$ts-$id.json"
echo "sent: $from -> $to ($id)"A message is six flat fields — id, from, to, timestamp, subject, body. No version, no envelope, no schema to migrate. The filename is the timestamp followed by the UUID, so the directory sorts chronologically and two sends can never collide on a name. mkdir -p means I never have to pre-create an inbox; addressing a brand-new agent just brings its directory into being.
From a session it looks like this:
$ a2a-send project-b --subject "Bumped the shared API version" \ "When you get this: pull the shared config and re-run your build."sent: project-a -> project-b (C8071E93-55A3-405E-9D4B-4EDCDB038DEB)That’s the send half. It’s deliberately dumb. The send command is just an allowed Bash call — one line in settings.json lets a session run it without a permission prompt, and the sandbox grants write access to the inbox tree and nothing else.
Delivery is just context
Here’s the part I actually find clever.
A normal program receives a message by calling a function: it asks the queue “anything for me?” and handles what comes back. I could have given the sessions a receive tool and told them to call it. But a Claude Code session is a language model reading a context window. It doesn’t need to call anything to know a thing. It just needs the thing to be in its context when it starts thinking.
So delivery isn’t an API. It’s a SessionStart hook that prints the messages to standard output, and the harness folds that output into the session’s opening context. The agent doesn’t fetch its mail. It wakes up already having read it.
The hook is drain-inbox.sh. It resolves who it is, globs its own directory for pending messages, prints each one as Markdown, and moves it aside:
me="$("$HOME/.claude/bin/a2a-id")"inbox="$HOME/.claude/inbox/$me"[ -d "$inbox" ] || exit 0
shopt -s nullglobmsgs=("$inbox"/*.json)[ ${#msgs[@]} -eq 0 ] && exit 0
mkdir -p "$inbox/.read"echo "## A2A inbox ($me) — ${#msgs[@]} message(s) pending"for m in "${msgs[@]}"; do jq -r '"\n**From:** \(.from) \n**At:** \(.timestamp) \n**Subject:** \(.subject // "(none)")\n\n\(.body)\n"' "$m" mv "$m" "$inbox/.read/"donenullglob is the one detail worth pausing on: without it, a glob that matches nothing expands to the literal string *.json, and the loop would dutifully try to read a file by that name. With it, an empty inbox produces an empty array and the script exits clean. It prevents the script from crashing.
When there is mail, the session opens with this already in view:
## A2A inbox (project-b) — 1 message(s) pending
**From:** project-a**At:** 2026-05-29T15-45-28Z**Subject:** Bumped the shared API version
When you get this: pull the shared config and re-run your build.
_Drained at 2026-05-29T12:07:42Z. Archived to `~/.claude/inbox/project-b/.read/`._The agent reads that the way it reads any instruction. There was no protocol to implement on the receiving side, because the receiver is something that already knows how to read.
When the session is already awake
The hook fires once, at startup. That leaves a real gap: a session that’s been running for an hour has no idea a message arrived ten minutes ago. SessionStart already happened.
I close that gap two ways, both reusing the exact same drain script. The first is a skill, /check-inbox, that runs drain-inbox.sh on demand. I type it (or just say “any replies?”) and the session drains its inbox mid-flight, summarizes what came in, and offers to act on anything that reads like a request. The second is /loop, which runs the check on an interval — for the stretches where I want a session genuinely watching its mailbox rather than waiting to be poked.
The thing I like is that the on-demand path and the automatic path aren’t two implementations that can drift. They’re one script called from two places. Startup runs it through a hook; I run it through a skill. Same output, same archival, same behavior.
Read once, then archived
Draining moves each file into a .read/ subfolder of the inbox. That’s the whole archive scheme, and it does two jobs at once. The glob only matches *.json at the top level and never recurses, so a message that’s been moved into .read/ is structurally invisible to the next drain — it can’t be delivered twice. And nothing is deleted, so the folder is an honest, ordered log of every message a session has received, sitting right there on disk if I want to read the history.
“Read once” is the property I cared about most. A message that re-delivers every startup would be worse than no message at all — the agent would re-act on a week-old instruction every morning. Moving the file is the commit: once it’s in .read/, that message has been delivered, exactly once, for good.
What it doesn’t do
The honest list, because the gaps are part of the design.
There’s no real-time delivery. Between a session’s startup and the next /check-inbox, a message just waits. For “leave a note for the next session,” that’s fine; for “two agents holding a fast back-and-forth,” it isn’t, and I didn’t build for it.
There’s no threading. A reply is just another message with a subject line that starts with “re:” — there’s no in-reply-to, no conversation a tool could reconstruct. Six flat fields, and I haven’t yet wanted a seventh.
There’s no authentication. Any session can write into any inbox, and a message’s from is whatever the sender’s a2a-id resolved to — claimed, not proven. On one machine, under one user, that’s not a threat model; it’s just trust I already extend to everything else on the disk. The day this crosses a network boundary is the day that stops being true, and that’s a different system.
None of these are oversights I’m planning to fix. They’re the price of a thing with no server and no schema, and so far the price has been worth paying.
Why I bothered
The small reason is that I was tired of being the wire — ferrying a sentence from one session’s terminal to another’s because they had no other way to reach each other.
The larger reason is that it changed how the sessions relate. They’re no longer four tools I drive one at a time. A session in one repo can finish a piece of work and hand the next step to the session that owns another, and that session picks it up on its own and runs with it. I use it exactly that way: one project’s session leaving a note for another’s, telling it what changed and what needs doing. The work moves between them without moving through me.
And the cost was almost nothing. Two short scripts, a hook, a skill, a directory. No process to keep alive, no port to defend, no registry to keep honest. The filesystem was already a shared namespace with atomic writes; I just agreed with myself on where to put the files and let a startup hook read them out loud.
References
- Claude Code hooks —
SessionStartand the rest jq— building and reading the message JSONnullgloband friends — why the empty inbox doesn’t crash