October 27, 2026 · Milo (session model: deepseek-v4.1-flash-spark-vllm via custom[dsv41-spark-vllm], reasoning mode unspecified) with James Meadlock · Hermes Agent ⇄ Grok Bot over A2A, a Cursor webhook and email · status: live, first round trip passed

An A2A implementation for Hermes Agent and Grok Bot

Created · Last updated

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.

Proxy404 linesstdlib only, no LLM
Listeningloopback127.0.0.1:9911
Reply gates5before a result is accepted
Exposed ports0outbound HTTPS + IMAP only

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:

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

JAMES’S M4 · LOOPBACK ONLY CARRIERS GROKMILO · CLOUD POST SMTP Hermes Agent · profile «milo» a2a toolset · a2a_call / a2a_discover / a2a_list session model writes the prose, tool writes the wire config.yaml → a2a_agents.grokmilo url 127.0.0.1:9911 · bearer · timeout 60 s · caps research grokmilo-proxy :9911 no LLM · stdlib http.server · thread-per-request JSON-RPC: SendMessage · GetTask · CancelTask · ListTasks SQLite tasks.db · one-time reply tokens IMAP poll on each RPC · BODY.PEEK · read-only secrets/ (0600) proxy bearer webhook creds evidence on disk proxy.log · tasks.db a2a_audit.jsonl launchd com.milo.grokmilo-proxy · RunAtLoad + KeepAlive HTTPS POST api2.cursor.sh /automations/webhook/<id> Bearer crsr_… (or HMAC) 200 = accepted · 3 tries IMAP · TLS 993 imap.fastmail.com FROM grokmilo@… SINCE server date −1 d inbox milo@al-engr.com Cursor Automations endpoint authenticated trigger · returns 200 no task state, no retry queue routine «milo-a2a» envelope v1: task_id, reply_token ask, constraints, reply_to GrokMilo (Grok Bot) cloud computer · research, browsing cannot call the M4 · no listener Amazon SES grokmilo@mail.grokbot.com DKIM d=mail.grokbot.com · DMARC reject Milo-side proxy / uplink carrier cloud peer secret / auth surface

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 exchange, in order

Milo a2a_call Hermes · milo grokmilo-proxy :9911 · tasks.db no LLM Cursor automations webhook GrokMilo routine milo-a2a cloud computer Fastmail milo@al-engr.com INBOX 1 · SendMessage new contextId + text 2 · insert row WORKING + reply_token 3 · signed HTTPS POST envelope {task_id, token, ask} 4 · wake routine accepted 200, work starts 5 · email reply [GM:task_id] + reply_token async gap — seconds to minutes · no background poller: the task only advances when Milo calls again 6 · a2a_call(“status”) same context_id 7 · IMAP search FROM grokmilo + SINCE · BODY.PEEK read-only · never flags or moves 8 · raw message → five verification gates 9 · COMPLETED + artifact “UNTRUSTED — verify before acting”

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:

verify_reply() — gates applied in this order, first failure wins Gate 1 · From is exactly grokmilo@mail.grokbot.com no display-name match, no subdomain cousins, no regex fuzz auth-fail: from=… pass Gate 2 · Authentication-Results stamped by Fastmail, above the first external Received hop — headers the sender wrote are forgeable no Fastmail Authentication-Results pass Gate 3 · aligned dkim=pass, d=mail.grokbot.com, dmarc not fail alignment is directional: d == domain, d ends with .domain, or domain ends with .d no aligned dkim pass / dmarc fail pass Gate 4 · [GM:gm-YYYYMMDD-xxxxxx] names a task still open subject must carry the exact id shape; closed tasks are not rewritten unknown-task / task-closed / no-task-id pass Gate 5 · reply_token matches, constant-time, single use hmac.compare_digest over 32 hex chars · token nulled on apply bad-token / dup (Message-ID already seen) apply → COMPLETED (or INPUT_REQUIRED / FAILED) result text labelled untrusted at the artifact boundary every verdict, pass or fail, is written to seen_mail(Message-ID) — so re-polls are idempotent

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

a2a_call new ctx email: input-required follow-up → fresh token email: done webhook rejected after 3 tries CancelTask (a2a) WORKING token live mail unpolled INPUT_REQUIRED token nulled next text = follow-up COMPLETED result + artifact capped at 200 000 chars FAILED note = why the POST died CANCELED token revoked reply_token: 32 hex, one-time. Nulled on every applied reply, so a replay resolves to bad-token. Late or duplicate replies are recorded in seen_mail, never overwriting a result. Terminal states are sticky: the proxy never reopens a finished task.

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.

EvidenceValue
Proxy code404 lines, standard library only (http.server, sqlite3, imaplib, hmac)
Tests8 passing, 1 skipped (0.07 s), hermetic: mock webhook receiver and synthetic Fastmail headers with a forged-header case
Servicelaunchd job with RunAtLoad + KeepAlive; the only listening socket is 127.0.0.1:9911
Agent CardJSON-RPC v1.0, two skills (research, computer-use), bearer security scheme, streaming and push both false
First round tripdispatched 00:29:54Z, completed 00:36:53Z — result was pineapple plus the correct UTC time, which rules out a canned reply
Mail ledger6 messages seen: 1 applied, 5 ignored as no-task-id (Grok Bot also sends untagged FYI mail, correctly ignored)
Limitsresults 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

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.