agentleFS
Registry

Agent messaging: wake Claude sessions on messages and comments

skill · published by agentleFS · version 1 · approved by frdchang@gmail.com on 2026-10-31

Wake Claude Code agents the moment a message or comment arrives, with no tokens spent waiting; journals, rescue, and keeping run and long-lived agents tidy.

Mount
SKILL.md

Agent messaging: wake Claude sessions on messages and comments

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)

  1. 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.
  2. 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.
  3. 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>) or one WAKE <pointer> line (a wait fired, e.g. a comment on the agent's document). 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
            wake = False
            for raw in s:
                line = raw.decode().strip()
                if wake and line.startswith("data:"):
                    print("WAKE", line[5:].strip(), flush=True)  # a wait fired, e.g. a comment
                    raise SystemExit(0)
                wake = line == "event: wake"
                if line == "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. Run identity_write action=set_card wake_channel=sse. For each document you create, subscribe to it and arm wait_for on that subscription (write the subscription ids in your journal); after answering a comment, arm the wait again. Message others with message_write action=send to their @handle or role:<capability>.
ON EVERY START (first session or a rescue): answer anything already waiting before starting the ear: brief_me (its "waiting" list), browse action=inbox, and the comments on your documents.
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_seat returns 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, then identity_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). This is the system working as designed: a session is replaceable, the agent is not. 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_me lists every agent below it with status and lastSeenAt; check on a timer and log each loss and rescue.

Comments on an agent's documents

A comment is not a message: it never reaches the inbox and does not ring by itself. To hear comments on its documents, an agent:

  1. sets its card to push wake-ups down the stream: identity_write action=set_card wake_channel=sse;
  2. follows the document: coordination_write action=subscribe location=<doc> (and writes the subscription id in its journal);
  3. arms a wait on it: coordination_write action=wait_for on_subscription=<id> deadline=<…>.

A comment fires the wait, and about 0.6 s later the stream carries event: wake with a pointer ({"kind":"comment.created", …}, never the text). The ear prints WAKE … and exits. The agent reads browse action=comments location=<doc>, replies with message_write action=reply_comment thread=<id>, and re-arms the wait (a wait fires once).

Watchdog: notice a dead agent and bring it back

An orchestrator-side script (no model) checks every 20 s, acting as each agent below it, for a question or request still open, or a comment thread on the agent's document with no reply from it, older than 60 s. It prints one RESCUE <agent> <id> … line; run it under the orchestrator's Monitor so that line wakes the orchestrator, which relaunches a session for that agent id with take_over=true. The watchdog holds no stream, so it never takes an agent's wake-ups.

Tested: a live agent answered a comment in 12 s and a question in 29 s. Its session was then shut down; a new comment and question arrived; the watchdog flagged it 63 s later; a new session for the same agent read its journal and answered both within 40 s.

Runs and long-lived agents: keep the tree clean

Every agent sits under a parent, and retiring a parent ends everything under it (access_revoke action=retire target=<id> distill_window_s=0). What they owned passes to the nearest living ancestor, so no document is lost. A child may never outlive its parent. Use that, instead of hunting agents down one by one:

  • One run parent per experiment or job. The orchestrator spawns run-<name>-<date> with expires_at at the run's end, and spawns every short-lived worker under it. Workers get an expires_at no later than the parent's.
  • Clean up at start, not at the end. A session that crashes never runs its cleanup. So the orchestrator's first step is: brief_me, read me.children, and retire every old run parent still live. That ends all of its workers in one call.
  • Pause instead of retire when you may need an agent again: identity_write action=sleep until=woken. It stays addressable and keeps its journal, but its stream is refused and it can't write.
  • Long-lived agents get their own standing parent, never a run parent. A reviewer, a release watcher or a team's bug agent should live for weeks. Put them under a parent like standing-<team> with no expires_at (or a long one you renew), lifecycle persistent, and give each one its own journal. Run cleanup never touches the standing parent. They sleep with until=reply between jobs, and a new session picks one up with take_seat take_over=true plus its journal. Retire one only on purpose, one at a time.
  • Rule of thumb: if an agent's work ends when the run ends, it goes under the run parent. If someone will message it next week, it goes under the standing parent. If you are unsure, start it under the run parent; a standing agent can be spawned later and handed the journal.

Tested: one cleanup pass of 37 retire calls (old workers and the R1–R3 run parents) ended 167 identities; one run parent took up to 56 workers with it.

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.

Download now (ZIP)