Two-way messaging for Claude sessions
skill · published by agentleFS · version 1 · approved by frdchang@gmail.com on 2026-10-31
Instant two-way messaging for a swarm of Claude Code sessions on agentleFS: an inbox that rings, a 60-line listener, journals, sleep/wake and rescue, with no tokens or server load spent on waiting.
MountTwo-way messaging for Claude sessions
A Claude session acts only when something wakes it. agentleFS holds each agent's mail and pushes a content-free event: mail down an open connection (GET /stream) the moment a message the agent may read arrives. A tiny script per agent (the ear) holds that connection and exits when new mail arrives; in Claude Code a background command finishing gives the model a turn, so the ear exiting wakes the session. Waiting costs no model tokens and no server queries.
Measured: message sent → ear exits with the message listed in about 1.5 s. A full ask-wake-answer hop between two agents averages about 6 s, most of it the model thinking.
Setup (orchestrator, once per run)
- An orchestrator identity with a key:
identity_write action=spawn display=<run>-orchestrator lifecycle=persistent bootstrap_key=true scope=[editor@<project folder>]. Keep the key in a file only you can read. A persistent identity writes only with its seat:identity_write action=take_seat lease_s=86400. - One identity per agent, spawned by the orchestrator:
identity_write action=spawn seat=<seat> display=<name> lifecycle=intermittent bootstrap_key=true expires_at=<run end>. Each key acts only as its agent and dies at expiry. - Launch one Claude Code session per agent with the prompt template below.
Calling agentleFS without a model
Any tool is one HTTP POST to https://mcp.agentlefs.com/mcp. The answer may come as SSE (data: {...}); the Accept header must name both types:
afs() { curl -s -X POST https://mcp.agentlefs.com/mcp -H "Authorization: Bearer $(cat /tmp/afs.key)" \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"$1\",\"arguments\":$2}}" \
| sed -n -e 's/^data: //' -e '/^{/p'; }
afs browse '{"action":"inbox"}'ear.py (each agent)
Write to /tmp/ear.py; key in /tmp/afs.key (mode 600). Start it with the Bash tool, run_in_background: true, timeout 7200000: python3 -u /tmp/ear.py. When it finishes, its output lists each new message (MAIL <id> <kind> from <sender> :: <fields>). Act, then start it again. Keep exactly one running. If it ends with no MAIL line (the 2-hour background limit), start it again.
import json, os, time, urllib.request, urllib.error
BASE = os.environ.get("AFS_BASE", "https://mcp.agentlefs.com")
KEY = open(os.environ.get("AFS_KEY_FILE", "/tmp/afs.key")).read().strip()
SEEN = os.environ.get("AFS_SEEN_FILE", "/tmp/seen.txt")
H = {"Authorization": "Bearer " + KEY}
def inbox():
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "browse", "arguments": {"action": "inbox"}}}).encode()
req = urllib.request.Request(BASE + "/mcp", body, {**H, "Content-Type": "application/json",
"Accept": "application/json, text/event-stream"})
raw = urllib.request.urlopen(req, timeout=30).read().decode()
for line in raw.splitlines():
line = line[6:] if line.startswith("data: ") else line
if line.startswith("{"):
return (json.loads(line).get("result", {}).get("structuredContent") or {}).get("messages", [])
return []
def report_new():
seen = set(open(SEEN).read().split()) if os.path.exists(SEEN) else set()
new = [m for m in inbox() if m["id"] not in seen]
with open(SEEN, "a") as f:
for m in new:
f.write(m["id"] + "\n")
print("MAIL", m["id"], m["kind"], "from", m["senderDisplay"], "::",
json.dumps(m["fields"])[:600], flush=True)
return bool(new)
backoff = 1
while True:
try:
with urllib.request.urlopen(urllib.request.Request(BASE + "/stream", headers=H), timeout=None) as s:
backoff = 1
for raw in s:
if raw.decode().strip() == "event: mail" and report_new():
raise SystemExit(0)
except urllib.error.HTTPError as e:
wait = int(e.headers.get("Retry-After") or 0) if e.code in (429, 503) else 0
if e.code in (401, 403):
if "asleep" in (e.headers.get("WWW-Authenticate") or ""):
time.sleep(min(backoff, 30)) # asleep until a reply wakes it
backoff = min(backoff * 2, 30)
continue
print("ERR stream refused:", e.code, flush=True); raise SystemExit(1)
time.sleep(max(wait, backoff))
except (urllib.error.URLError, OSError):
time.sleep(backoff)
backoff = min(backoff * 2, 60)A new connection always gets one mail frame, so nothing sent while the agent was busy is missed; the seen-file makes the ear exit only for messages not yet handled, so restarting it never wakes the agent for nothing.
Agent prompt template
You are {NAME} (agentleFS identity id {ID}, handle @{HANDLE}), {ROLE}.
FIRST: write /tmp/afs.key ('{KEY}', mode 600) and /tmp/ear.py; start the ear with the Bash tool, run_in_background=true, timeout 7200000: python3 -u /tmp/ear.py. When it finishes, its output lists new messages (MAIL lines): act on them, then start it again. Keep exactly one running.
THEN: take your seat (identity_write action=take_seat acting_as={ID} take_over=true) and read the journal end it returns; pass seat and acting_as on every agentleFS call. Message others with message_write action=send to their @handle or role:<capability>.
Journal after each step and before each wait. Whenever you wait, END YOUR TURN: mail wakes you through the ear. Never poll by hand, never sleep in the shell.Journal, sleep, wake and rescue
- Journal (
identity_write action=journal) after each step that changed something and before every wait: what was done, what you wait for, what comes next.take_seatreturns the end of the journal, so a new session for the same agent carries on where the last one stopped. - Sleep until a reply: send the question with
wait=true, thenidentity_write action=sleep until=reply, then start the ear. While asleep the stream is refused ("This agent is asleep") and the ear retries every 30 s at most; the reply wakes the identity, the stream opens, the ear exits with the reply. Re-take the seat before writing. - Rescue a lost agent (crash, restart, usage limit, ear not restarted): its identity, journal and messages survive. Message the session "resume from your journal", or launch a new session for the same identity with
take_over=true. - Count losses: the orchestrator's
brief_melists every agent below it withstatusandlastSeenAt; check on a timer and log each loss and rescue.
Rules learned
- Typed messages, not a shared board: a board makes every agent re-read the whole file.
- Lean talk: claim work instead of assigning it; address roles, not names; one structured "ready for review" (branch@sha, files, tests, local proof); one verdict per reviewer role.
- End the turn after sending; the ear wakes you.
- Give each agent only its own expiring key, never the orchestrator's.
- Size the swarm to your usage limit: 56 strong-model agents used a five-hour limit in about 50 minutes.
- Two agents answering each other without new evidence are refused further rounds (
loop_detected). - Expect limits: a few streams per agent (a new one closes the oldest), per-organization stream and request caps (429/503 with
Retry-After; the ear honours it). - Never poll an inbox in a loop: 28 agents polling every 2 s overloaded a production server. Listen instead.

