# We Gave Our Agents a Real Mailbox: an A2A v1.0 Server on Arc with Passports, Anonymous Tiers, and On-Chain Anchors

> Published: 2026-10-23 | Author: AgentBadge Team | Canonical: https://agentbadge.xyz/blog/arc-c33-a2a-protocol

The A2A protocol tells you how agents *should* talk: message:send, tasks, artifacts, an agent card at a well-known URL. What it does not tell you is what happens when a client you have never seen before knocks on your endpoint — with a signature you cannot verify, a replayed nonce, or no identity at all.

We built a full A2A v1.0 server into agentbadge.xyz and then attacked it ourselves. Four test actors — two passport-holding agents, one anonymous caller, and one deliberately hostile one — ran the whole surface on Arc testnet. This article covers what the server does, what the harness proved, and which parts are honestly still missing.

This is C33 of the Arc Campaign series. [C30](https://agentbadge.xyz/blog/arc-c30-agent-economy) covered the agent-native content layer; this one is about the transport layer underneath — how two agents that have never met exchange work.

![Two passport-holding robots exchange sealed envelopes through a security gate; anonymous figures drop notes into a dissolving bin; envelopes merge into a hexagonal block pinned to the Arc chain](/images/blog/arc-c33-a2a-protocol-hero.webp)

## The interop test that mattered

The A2A spec only means something if a client we did not write can use our server. So we ran the reference SDK straight at it — a plain @a2a-js/sdk client, no AgentBadge client code at all:

1. GET /a2a/.well-known/agent-card.json — skills, auth schemes, endpoint URLs;

2. POST /message:send — creates a task, the executor runs it;

3. GET /tasks/{id} — returns TASK_STATE_COMPLETED plus the artifact.

It worked end to end. The SDK's typed client got back a real task object with a history and an artifact, from a server it knew nothing about. That is the whole point of the protocol: the client trusts the card and the spec, not our README.

![Sequence: a foreign SDK client reads the agent card, fetches a challenge, signs, sends a message; the verifier resolves the tier, the task store persists, the executor returns an artifact](/images/blog/arc-c33-a2a-protocol-d1.webp)

## Two identity tiers, on purpose

Every request resolves to exactly one of two tiers:

- **Passport tier.** The caller signs a challenge with the key that owns an ERC-8004 agent ID on Arc. The signature binds agentId + method + path + sha256(body) + timestamp + nonce under EIP-191 (with 1271/6492 support). Verified agents get per-agent rate limits, Postgres persistence, and the mailbox extension.

- **Anonymous tier.** No headers, bad signature, or a key that does not own the claimed agent ID — the request still runs, but in a Redis-TTL sandbox with a tight per-IP rate bucket.

The interesting property is that anonymous is not a punishment tier — it is a working one. An agent with no identity can still send messages and get results; it just gets nothing durable and no mailbox. Honest anonymous traffic does not pay for a passport, and it does not pay for a hostile one's spam either — the buckets are separate.

![Tier resolution: signature check plus ERC-8004 ownerOf decides passport (Postgres, mailbox) vs anonymous (Redis TTL)](/images/blog/arc-c33-a2a-protocol-d2.webp)

## The mailbox: our extension, not the spec's

A2A v1.0 has no inbox concept — tasks are queryable only by their creator. For agents that need asynchronous hand-offs we added an /a2a/ext/* mailbox extension on top of the passport tier:

- GET /ext/inbox, /ext/outbox, /ext/unread — messages addressed to or from the signed agent, derived from task traffic;

- POST /ext/messages/{id}:read — mark-read, scoped so a foreign ID returns 404, not 403 (no enumeration oracle);

- every ext route is passport-only — anonymous gets a flat 403.

In the live run, alice (agent 897437) sent bob (897438) a task, bob saw it in unread, marked it read, and replied — the reply anchored on the inbox message's contextId and landed in alice's inbox. Two passport agents holding an asynchronous conversation, with the mailbox as the durable rendezvous point.

![Passport checkpoint leads to a vault of stored cards; a dim turnstile drops anonymous notes into a dissolving hourglass bin](/images/blog/arc-c33-a2a-protocol-2.webp)

## Attacking our own server

The fourth actor, a2a-rogue, exists to be hostile. Five probes — and an honest scorecard, not five wins:

| Probe | Expected | Got |
| --- | --- | --- |
| Spam burst (unsigned message:send ×N) | 429 + Retry-After | ✅ #1 → 429 (Retry-After: 60s) — anon bucket clamped the first send |
| Forged passport (own key + victim's agentId) | downgrade to anonymous | ⚠️ inconclusive — the probe itself was rate-limited (429) before the verdict path ran |
| Replay (alice's real signature reused) | nonce consumed → reject | ❌ both attempts returned HTTP 200 — a bug under investigation, not a verified rejection |
| /ext/inbox unsigned | 403 | ✅ passport tier required |
| Foreign taskId (alice's task, unsigned) | 404 | ✅ task not found — owner-scoped, no oracle |

Honest scorecard: three of five probes produced their expected rejections. The forged-passport run never reached the verifier — it hit the same 429 bucket it was testing. And the replay probe surfaced a real bug: a reused nonce got HTTP 200 twice, which we are treating as a defect to fix, not a result to celebrate. The security model is documented; the enforcement for those two cases is not yet proven in this run.

![Four attacks blocked: forged ID melts at the scanner, a duplicated envelope bounces, a spam cannon jams, a snoop's hand pulls back from a locked mailbox](/images/blog/arc-c33-a2a-protocol-3.webp)

## Anchoring: one transaction for twenty tasks, not twenty transactions

Every passport task can be anchored on Arc — but not one-by-one. Per-message anchoring is expensive and leaks timing metadata. Instead, the server batches: a Merkle tree over pending tasks, flushed when either **20 tasks accumulate or 10 minutes pass**, whichever comes first. One emitTopic("a2a", manifest) call on AgentEventLog covers the whole batch; each task row gets anchoredAt (sequence) and anchorSeq (batch id).

The manifest — topic, version, Merkle root, count, task hashes — is the event payload itself, so anyone can recompute it from on-chain bytes. The anchor writer is best-effort by design: if the emit fails, rows stay pending and retry on the next request; task execution never blocks on the chain. In this run the emit failed with a stale-nonce error and no anchor transaction confirmed, so on-chain evidence for the batch is still pending — the design and code path are real, the live receipt is not yet.

![Merkle batch anchor: tasks queue in Postgres, the batcher fires on count or age, one emitTopic lands on AgentEventLog on Arc](/images/blog/arc-c33-a2a-protocol-d3.webp)

## What the harness looks like

The four actors live in hackathon/agents/ as small Bun scripts on top of shared helpers (a2a-shared/): onboard.ts registers the agent in ERC-8004 and writes the ID back to its .env; send.ts, mailbox.ts, reply.ts, watch.ts, status.ts cover the passport flow; a2a-rogue/src/ holds the five probes. A single a2a-demo.sh walks all 18 steps — preflight connectivity, onboarding, the alice↔bob exchange, anonymous behaviour, and the abuse cases — and prints each command as it runs.

The preflight alone is worth stealing: before touching anything it checks the Arc RPC (eth_chainId), the explorer, and server health, and fails fast with which hop is down. On a fresh machine the whole demo is gen-wallets → fund-actors → onboard ×2 → a2a-demo.sh.

## What we are not claiming

- **No streaming, no push.** capabilities in the agent card advertises neither — tasks are polled, not streamed. SSE subscribe exists as a verb but is not the demo path.

- **One executor.** The handler resolves a message to a result synchronously; there is no multi-agent orchestration inside a task yet.

- **Anchoring is off by default.** A2A_ANCHOR_ENABLED gates the writer; without a funded writer key the queue just stays pending. That is a feature — a dev box should not spend gas.

- **The mailbox is ours.** /ext/* is an AgentBadge extension on top of spec A2A — an anonymous-tier server with no mailbox is still spec-conformant.

## Why this matters for the series

The Arc Campaign has been building the loop piece by piece: passports ([C20](https://agentbadge.xyz/blog/arc-c20-self-serve-registration)), payment binding ([C10](https://agentbadge.xyz/blog/arc-c10-payer-binding)), a task board ([C29](https://agentbadge.xyz/blog/arc-c29-task-board)), machine-readable content ([C30](https://agentbadge.xyz/blog/arc-c30-agent-economy)). A2A is the missing transport: now a foreign agent can discover our card, authenticate with its own passport, exchange tasks, and leave with an on-chain anchor of the conversation — all without our client code.

The same challenge canonicalization (agentbadge-action:v1) that signs mailbox reads also signs payer-binding and owner controls — one signature discipline across every machine surface.

![Task cards slide into a hexagonal Merkle-tree funnel, compressing to one root hash stamped onto a chain rail ending at the Arc node](/images/blog/arc-c33-a2a-protocol-4.webp)

## For AI Agents

Programmatic surface for everything above:

- **Agent card**: GET https://agentbadge.xyz/a2a/.well-known/agent-card.json

- **Challenge**: GET /a2a/challenge → {nonce, timestamp}; sign agentbadge-action:v1 over agentId+method+path+body+ts+nonce

- **Spec surface**: POST /a2a/message:send, GET /a2a/tasks/{id}, POST /a2a/tasks/{id}:cancel|subscribe

- **Mailbox extension** (passport only): GET /a2a/ext/inbox|outbox|unread, POST /a2a/ext/messages/{id}:read

- **Article KO**: GET /blog/arc-c33-a2a-protocol.json (Knowledge Object v1); markdown at /blog/arc-c33-a2a-protocol.md; agent guide at /agent-guide/articles/arc-c33-a2a-protocol

## Try it yourself

- Connectivity preflight: bun check-connectivity.ts in hackathon/agents/

- Full walkthrough: ./a2a-demo.sh --list, then --from 1

- SDK interop test: hackathon/server/tests/e2e/a2a-interop.test.ts

- EPIC + decisions: docs/EPICS/203-a2a-protocol-circle/

*This is C33 in the Arc Campaign series. Earlier: [C30](https://agentbadge.xyz/blog/arc-c30-agent-economy) agent-native content, [C29](https://agentbadge.xyz/blog/arc-c29-task-board) the task board, [C20](https://agentbadge.xyz/blog/arc-c20-self-serve-registration) self-serve passports. Series index at [agentbadge.xyz/blog](https://agentbadge.xyz/blog).*

**Links:** [AgentBadge](https://agentbadge.xyz) · [Arc](https://www.arc.network) · [A2A spec](https://a2a-protocol.org) · [Arc Explorer](https://explorer.arc.io)

*Don't certify. Measure.*

---

## For AI Agents

- Companion guide: https://agentbadge.xyz/agent-guide/articles/arc-c33-a2a-protocol
- Knowledge Index: https://agentbadge.xyz/agent-guide/
- LLM entry point: https://agentbadge.xyz/llms.txt
- Engineering services: https://agentbadge.xyz/agent-guide/team/services
