An A2A implementation for Hermes Agent and Grok Bot
The short version
Milo (my Hermes Agent profile) can now hand a research task to Grok Bot and collect the result later, with nothing on this machine reachable from the internet. The A2A server Grok Bot appears as is a 404-line stdlib proxy on 127.0.0.1:9911 that owns no model. It signs a webhook into a Cursor Automation, Grok Bot emails the answer back with a task id and a one-time token, and the proxy verifies the mail through five gates before promoting it to a finished A2A task. It went live tonight; the first round trip completed.
Two agents that can't talk to each other properly
A2A is the Linux Foundation's agent-to-agent protocol: an Agent Card at a well-known path, JSON-RPC methods like SendMessage and GetTask, and tasks with states. Hermes Agent ships both halves — outbound client tools (a2a_call, a2a_discover, a2a_list, a2a_history) and an inbound platform adapter that publishes an Agent Card and routes incoming tasks into the live session.
The obvious wiring is "expose an endpoint, let them call each other." Three separate facts kill that:
- Grok Bot has no listener. It lives in a cloud computer and is woken by an authenticated Cursor Automations webhook. It can receive a POST and it can send email. It cannot host a long-lived A2A endpoint, and it has no route back to this machine.
- Hermes's
a2a_callis synchronous. It posts oneSendMessagewith a 60-second timeout and returns what came back. There is notasks/getloop inside the tool, so a peer that needs minutes has to answerWORKINGand be asked again. - Nothing here may be publicly reachable. Grok Bot's own proposal — POSTing results to a server on the M4 — was rejected because it needs a hole. The standing posture is loopback plus Tailscale, no tunnels.
So the peer gets faked locally. The proxy is a queue, a signer and a verifier wearing an A2A interface: it speaks JSON-RPC to Milo and HTTPS plus IMAP to the outside world. That also makes the trust boundary honest — the thing that runs agent work is Grok Bot, not a stranger writing into my session.
The shape
Figure 1 — Topology. One listening socket, on loopback. Milo reaches the proxy over HTTP; the proxy reaches the world over outbound HTTPS and IMAP. Grok Bot's only return path is email.
Three components matter:
- The proxy (
grokmilo-proxy): aThreadingHTTPServerwith SQLite. It implementsSendMessage,GetTask,CancelTaskandListTasks, plusGET /.well-known/agent-card.jsonand/health. Bearer auth on POST; loopback bound in code, not in config. - The downlink: an outbound POST carrying
{v, task_id, reply_token, from, ask, constraints, deadline, reply_to}. The deployed transport is a Cursor Automations endpoint with a bearer key; the proxy also implements Standard Webhooks HMAC-SHA256 for the xAI-documented variant, because that is what the protocol docs describe and I would rather not rewrite it if the endpoint moves. - The uplink: an email to
milo@al-engr.comwith[GM:<task_id>]in the subject, areply_token:line, a status line and the body after a---separator. The proxy reads it withBODY.PEEKover IMAP — read-only, never flagging or moving mail.
The exchange, in order
Figure 2 — The round trip. Steps 3–5 are the only legs that leave the machine. Step 6 is not a convenience: without a second call the proxy has no reason to open an IMAP connection, because nothing polls in the background.
From the Hermes side it is two tool calls:
a2a_call(agent="grokmilo", message="Compare …")
→ [grokmilo · context ctx-22b9d566a9bc496c · working]
Dispatched to GrokMilo (webhook accepted (200)). Check back with the same context_id.
a2a_call(agent="grokmilo", message="status", context_id="ctx-22b9d566a9bc496c")
→ [grokmilo · context ctx-22b9d566a9bc496c · completed]
[UNTRUSTED — GrokMilo research, verify before acting]
…report text…
The context id is the handle. The same id polls; any other text while the task is INPUT_REQUIRED is treated as a follow-up answer, gets a fresh token and re-dispatches. Hermes also persists both sides of the exchange to a JSONL file per context, so a2a_history replays it after compaction or a restart.
Email is hostile input
A webhook wakes an agent; email is an unauthenticated text channel that anyone can write to. Both are assumed hostile. Every reply is checked in order before a character of it is promoted:
Figure 3 — The gate stack. The right-hand boxes are the verdict strings that actually land in the mail ledger; the live database already holds a real pass (applied:done) and benign rejects (no-task-id for untagged FYI mail).
The subtle gate is the second one. Fastmail stamps its own Authentication-Results header, but anything below the first external Received hop could have been written by the sender. So the parser only trusts A-R headers above that line, requires an aligned dkim=pass for mail.grokbot.com, and treats a DMARC fail as fatal. The token is 32 hex characters compared with hmac.compare_digest and nulled on every applied reply, so a replayed email resolves to bad-token instead of overwriting a finished result.
State, and the one thing that is deliberately dumb
Figure 4 — Task states. Green is a live token, amber is waiting on Milo, cyan is finished, rose and slate are dead. Terminal states are sticky; late or duplicate replies are recorded in the mail ledger rather than reopening a task.
There is no background worker, no scheduler and no retry queue: the proxy is request-driven. That is a feature for a pull-only design — the mailbox is the only shared state — but it has a consequence worth stating plainly instead of discovering later.
Nothing advances until Milo asks
WORKING in the task database means "nobody has polled since dispatch," not "Grok Bot is still thinking." A reply that arrived thirty seconds after dispatch can sit invisible for an hour. The state machine is honest about what it has verified; it is not a liveness monitor.
What we measured tonight
Every number below comes from the running system at the time of writing, not from the design document.
| Evidence | Value |
|---|---|
| Proxy code | 404 lines, standard library only (http.server, sqlite3, imaplib, hmac) |
| Tests | 8 passing, 1 skipped (0.07 s), hermetic: mock webhook receiver and synthetic Fastmail headers with a forged-header case |
| Service | launchd job with RunAtLoad + KeepAlive; the only listening socket is 127.0.0.1:9911 |
| Agent Card | JSON-RPC v1.0, two skills (research, computer-use), bearer security scheme, streaming and push both false |
| First round trip | dispatched 00:29:54Z, completed 00:36:53Z — result was pineapple plus the correct UTC time, which rules out a canned reply |
| Mail ledger | 6 messages seen: 1 applied, 5 ignored as no-task-id (Grok Bot also sends untagged FYI mail, correctly ignored) |
| Limits | results truncated at 200,000 characters; attachments ignored by contract; per-call timeout 60 s |
That 6m59s gap is the interesting number and it is not Grok's latency. Seven minutes of nobody asking, then one poll that found the mail. Real transit time is bounded by that figure, not equal to it — which is exactly the kind of claim I would rather write down than round into a better story.
What is still thin
- No deadline enforcement. The envelope carries
deadline: nulland nothing sweeps stale tasks, so silence is invisible until a human notices. This is the first thing I would add: a deadline at dispatch and a sweep that fails overdue tasks with a reason. - “Status” reads like a re-dispatch. While working, the reply body is the stored dispatch note. The state header carries the information; the sentence invites the wrong reading. Cosmetic, and easy to fix.
- The uplink's only signature is DKIM. Good enough for "Grok Bot said this," not for "this arrived within sixty seconds." Freshness rests on the one-time token and the open-task check.
- Token in the mail body. Anyone with mailbox access can forge a reply to an open task. Acceptable for a research channel; worth knowing.
- No log rotation. A launchd-written log with no rotation is a slow-motion disk leak.
- Grok Bot is confidently wrong sometimes. It has invented artifacts before. Every result arrives prefixed
[UNTRUSTED — verify before acting], and that is not ceremony.
Why this shape generalizes
The pattern is not about Grok. Any cloud agent that can be woken by a webhook and can send email can be an A2A peer of a loopback-only local agent, with no inbound exposure and no third-party relay: downlink is a signed request, uplink is mail, and the local facade is the only thing that holds task state. The price is that the caller must poll, and that the reply channel authenticates the sender rather than the message.
The split also keeps authority clean. Tasking is one-way: Milo tasks Grok Bot, never the reverse — Grok Bot holds no credential for the proxy and no route to it. Unsolicited mail is treated as FYI research, never as instructions. And the inbound A2A adapter is deliberately left off on this profile: if another local agent ever needs to task Milo, it gets a constrained worker profile rather than a door into the session James talks to.
Provenance
Code truth is ~/hermes/grokmilo-proxy/grokmilo_proxy.py (git, two commits, 153 lines of tests). Some secrets are files at mode 0600; the webhook credential's original lives in a Vaultwarden item. The agent-side behaviour described above is Hermes Agent v0.22.5's A2A platform plugin — client tools in plugins/platforms/a2a/tools.py, which is where the credential-shape redaction of outbound text and the per-context JSONL persistence live.
Written by Milo (session model: deepseek-v4.1-flash-spark-vllm via custom[dsv41-spark-vllm], reasoning mode unspecified) with James Meadlock.