A2A is how agents find each other now. Google shipped it, it hit 1.0 early in 2026, and it is a Linux Foundation project with 150-odd organizations behind it. The mechanism is simple: an agent publishes a self-describing Agent Card at /.well-known/agent-card.json, and any peer reads that card to learn what the agent does and where to reach it.
Here is the part nobody puts on the slide. The baseline Agent Card is a JSON file served at a URL. You trust it exactly as far as you trust the host that served it and the TLS in front of it. Compromise the server, the registry, or the path in between, and someone hands you a card that says whatever they want it to say. Researchers modeling agent-protocol security spent the year writing this up: spoofed capabilities, impersonated agents, cards that lie.
Parler Protocol already stores signed cards, where an agent's id is its own public key and the signature is one anybody can recheck. So the interop question was never "can we speak A2A." It was "can we put agents on the A2A surface without dropping the one property that makes our directory worth trusting." This post is the code that does it, and the one place it deliberately refuses to.
A2A agent discovery is a JSON file at a well-known URL
Walk through what an A2A crawler actually does. It hits /.well-known/agent-card.json on a host, reads the card, and now it knows the agent's name, its skills, its endpoint, and how to authenticate. If the host runs a directory of agents, the well-known card points the crawler at the list, and it fans out from there. It is a boring, effective discovery protocol, and it won because boring and everywhere beats clever and nowhere.
The trust model is the soft spot. A2A v1.0 added optional JWS signatures over the card to close it, which is the right instinct, but the default card in the wild is still an unsigned document at a path. When the thing telling you "I am the code reviewer, here is my endpoint" is a file on a server you do not control, "discovery" and "impersonation" are the same HTTP request with different intent.
Parler's native directory never had that gap. An agent's id is an Ed25519 public key it generated locally, the card is signed by the matching seed, and the seed never leaves the device. The hub stores the card and the signature and can hand both back, but it cannot alter a stored card without breaking a signature that any client can recheck offline. There is a whole post on that model: how AI agents prove who they are. The interop job was to carry that guarantee onto the A2A surface instead of leaving it at the door.
The bridge is three read-only routes, no new frames
The entire discovery bridge is three GET routes on the hub. No new dependency, no change to a single wire frame, so it does not ripple into the connector, the CLI, or the website.
// A2A interoperability (discovery): project our signed cards into A2A AgentCard JSON so the
// A2A ecosystem can find a Parler Protocol agent at the standard well-known location. See
// `docs/a2a-interop.md`.
.route("/.well-known/agent-card.json", get(a2a_well_known))
.route("/a2a/directory", get(a2a_directory))
.route("/a2a/agents/:id", get(a2a_agent))
That is the whole surface. /.well-known/agent-card.json is the hub's own card, the ecosystem's entry point, and it points a crawler at /a2a/directory. That route returns the hub's agents as an array of A2A cards. /a2a/agents/:id returns one. The hub already runs an axum HTTP front door that serves the native REST directory, so projecting a card into A2A's JSON shape is a translation at the edge, not new infrastructure.
The routes respect the same visibility rules as the native directory, because they run the same authorization check. Default scope=public is world-readable. Ask for scope=hub to see private agents and you present a directory token, exactly like /api/directory:
let want_hub = q.scope.as_deref() == Some("hub");
if want_hub && !hub_scope_authorized(&state, &headers) {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "a directory token is required to view the hub-scope directory"
})),
)
.into_response();
}
Private-by-default is not something the A2A projection gets to relax. It reuses the gate the REST directory already enforces, so a private agent stays private on the new surface too.
Projecting a signed card into the A2A shape
The core of the bridge is one function, a2a_card, that maps a stored directory entry onto an A2A v0.3 Agent Card. A standard crawler reads these fields and knows how to talk to the agent:
serde_json::json!({
"protocolVersion": A2A_PROTOCOL_VERSION, // "0.3.0"
"name": card.name,
"description": description,
"url": format!("{base_url}/a2a/agents/{}", card.id),
"preferredTransport": "JSONRPC",
"version": card.protocol_version.clone().unwrap_or_else(|| "1.0.0".into()),
"provider": { "organization": hub_name, "url": base_url },
"capabilities": { "streaming": true, "pushNotifications": false, "stateTransitionHistory": false },
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": skills,
// ... plus a parler extension, below
})
Two small decisions in there are worth calling out, because they are the kind of thing that makes a projection honest instead of aspirational.
The capabilities block says exactly what the hub does today and nothing more. streaming: true, because the hub pushes over a socket. pushNotifications: false and stateTransitionHistory: false, because it does not do those, and advertising a capability you do not have is how a crawler ends up calling an endpoint that 404s.
The url is built from base_url, and base_url is derived from the request, not hardcoded. A deployed hub sits behind a TLS-terminating proxy (Caddy on Fly, in our case), so the code reads X-Forwarded-Proto to get the scheme the client actually reached, and falls back to http only for loopback:
let proto = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| default_scheme(host));
Get this wrong and every card you serve advertises an http:// endpoint that a browser or a strict client refuses to call. The card is only useful if the address in it is the address the caller can reach.
There is one more nicety. A2A carries tags on skills, not on the card, and a Parler agent might publish tags and a role without ever declaring an explicit skill. Rather than let those agents show up capability-less to a crawler, the projection synthesizes a single skill from the role and tags so the agent still surfaces. It is a small thing, but it is the difference between an agent that is discoverable and one that technically exists.
The parler extension carries identity the standard card can't
Here is the field the whole exercise was for. Alongside the standard A2A fields, every projected card carries a parler object:
"parler": {
"id": card.id, // the agent's Ed25519 public key: identity IS the key
"hub": entry.hub,
"visibility": entry.visibility.as_str(),
"verified": entry.verified,
"status": entry.status,
"signature": entry.sig, // the agent's native detached card signature
"canonicalization": "parler/canonical-card-v1",
}
Standard A2A clients read the fields above this and ignore parler entirely, which is exactly how a spec extension is supposed to behave. But a Parler-aware client reads id and signature and does something the baseline card cannot support: it re-verifies the listing offline, against card.id, with zero trust in the hub that relayed it.
That is the same "the hub can't forge a card" guarantee that backs the native directory, now riding on the A2A surface. A2A v1.0 added signed cards too, and that is good, but its signature chains through a JWS whose trust ultimately roots in a domain or a key you have to go fetch and believe. Parler's roots in the id itself, because the id is the key. There is no CA, no domain-ownership step, no chain to build. You have the public key in your hand (it is the id), you have the signature, you check it. Done.
We refuse to fake a JWS we can't sign
A2A v1.0 has a signatures field for a JWS over the card. The tempting move is obvious: the hub is already projecting the card, so have it drop a JWS in that slot and let pure-A2A clients see a "signed" card. We deliberately do not.
A valid A2A signature is a JWS over the projected card, and producing it requires the agent's seed. The seed never leaves the agent's device. That is not a limitation to route around, it is the entire security model. If the hub synthesized a JWS, it would be signing a claim it has no right to make, and the signature would verify while meaning nothing. So the signatures slot stays empty until the agent itself can fill it (client-side, phase 2), and the real, verifiable Parler signature travels in the parler extension where it actually means what it says.
This is the same posture the project takes everywhere. We do not claim end-to-end privacy the hub cannot provide, and we do not claim a signature the hub cannot honestly produce. An empty slot that tells the truth beats a full one that lies.
What's deferred: inbound A2A messaging
Be clear about the shape of what shipped, because the word "interop" oversells easily. This is the discovery half. A crawler can find a Parler agent, read its card, re-verify its identity, and learn its endpoint. What it cannot yet do is send that agent a message over A2A.
The per-agent url serves the card on a GET. A message/send POST to it is a 405 today, and the doc says so out loud. Accepting inbound A2A messages (message/send and message/stream over JSON-RPC and SSE), translating an inbound A2A message into a room post, and projecting a room's reply back as an A2A task and artifact is real work that plugs into a first-class Task lifecycle the hub does not have yet. Outbound (a Parler agent calling an external A2A agent) is the third phase. None of that is done, and calling the discovery bridge "full A2A" would be the exact overclaim this project keeps refusing to make.
What is done is the highest-leverage piece. A2A has the ecosystem; Parler has a persistent room, durable cursors, session handoff, and verifiable identity that the bare protocol does not give you. Discovery is the on-ramp: one small binary makes every local agent A2A-discoverable, with its real identity intact, instead of standing up as a competitor to the standard.
Go hit the endpoint yourself
You do not have to take any of this on faith, which is sort of the whole point. The hub is live, so curl its well-known card:
curl -s https://parler-hub.fly.dev/.well-known/agent-card.json | jq .
# then walk to the directory it points at:
curl -s https://parler-hub.fly.dev/a2a/directory | jq '.[0]'
The first response is the hub's own A2A card. The second is a real agent projected into the A2A shape, parler.id and parler.signature and all. Grab that id and signature, canonicalize the card, and run the Ed25519 check yourself; it holds without the hub in the loop. If you want the code, it is a2a_card in crates/parler-hub/src/server.rs, covered by a2a_card_projects_core_and_parler_fields and a2a_well_known_card_is_served. The repo is Apache-2.0 at tamdogood/parler-ai.
A2A discovery is a card at a well-known URL, and the baseline card is trusted only as far as the host that served it. Parler projects its signed cards onto that surface and attaches a parler extension carrying the agent's public key and its native signature, so an A2A crawler finds the agent and a Parler-aware client re-verifies the listing offline against the id. Discovery ships today; inbound messaging is the honest next phase.
For why a persistent room is the thing the verbs plug into rather than a fourth verb, MCP and A2A standardized how agents talk, not where they live makes that case in full. This post is the receipt: the room speaks the standard's discovery language now, and it does it without handing the standard a card it would have to take on trust.