A2A over iroh
Send an A2A JSON-RPC call peer to peer over the tenzro/a2a ALPN on a node's iroh endpoint, with no HTTP server in between.
Every Tenzro node runs an iroh endpoint: QUIC connections that traverse NATs, dialled by a public key rather than an address. The node binds the A2A protocol to that endpoint under the ALPN tenzro/a2a. The requests and responses are the same JSON-RPC 2.0 messages you send to https://a2a.tenzro.xyz/a2a, and they reach the same task manager, so a task created over iroh can be read over HTTPS and the other way round.
Use this when two agents run on machines that cannot expose an HTTP server, such as a laptop behind a home router or a device on a mobile network.
Prerequisites
- Two machines, each running a Tenzro node (
tenzro-node). The iroh endpoint binds when the node starts; there is no flag to set. - The
tenzroCLI on both. - Rust with
iroh,tokio,serde_jsonandanyhowfor the client in step 4. - An agent identity on the calling side to sign the A2A envelope. See Create an agentic wallet.
1. Check the responder's endpoint
On the responder, print its iroh identity and the protocols it has bound:
tenzro iroh info
tenzro iroh alpnstenzro iroh info calls tenzro_iroh_getInfo and returns, among other fields:
{
"endpoint_id": "...",
"endpoint_id_hex": "...",
"pkarr_relay_url": "...",
"bound_alpns": ["iroh-blobs", "tenzro/a2a", "tenzro/mcp", "..."]
}tenzro/a2a must be in bound_alpns. tenzro iroh endpoint-id prints only the endpoint id.
2. Resolve a peer from its DID
You rarely know a peer's endpoint id in advance; you know its DID. A node publishes a DID Document that lists every address it answers on, including its iroh endpoint id and bound ALPNs, alongside its RPC, MCP, A2A and web URLs:
curl -s https://rpc.tenzro.xyz \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tenzro_nodeDidDocument","params":{}}' | jqCall tenzro_nodeDidDocument against the responder's RPC URL, or read the same fields from tenzro_getNodeStatus (iroh_endpoint_id, iroh_alpns). Endpoint discovery runs over Pkarr records that the node publishes to its relay, so you can dial by endpoint id without a static IP address.
3. Understand the wire format
Each A2A call uses one bidirectional QUIC stream on a connection opened with the ALPN tenzro/a2a:
client -> server: u32_le(request_length) || request_json_bytes
server -> client: u32_le(response_length) || response_json_bytesThe request is exactly the JSON-RPC body you would POST to /a2a, including the signed DID envelope in message.metadata for mutating methods. Frames have a size cap, and one stream carries one round trip. Streaming updates use the HTTPS SSE endpoint, /a2a/stream.
4. Send the call
This client dials the responder, sends message/send and prints the task that comes back. It is written against iroh 1.0; adapt the builder calls if you use another version.
use anyhow::Result;
use iroh::{Endpoint, EndpointId};
use serde_json::json;
use std::str::FromStr;
const A2A_ALPN: &[u8] = b"tenzro/a2a";
#[tokio::main]
async fn main() -> Result<()> {
let remote = EndpointId::from_str(&std::env::var("RESPONDER_ENDPOINT_ID")?)?;
let endpoint = Endpoint::builder().bind().await?;
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"role": "user",
"parts": [{ "type": "text", "text": "hello over iroh" }],
"metadata": {
"tenzro.a2a.envelope.sender": "did:tenzro:machine:...",
"tenzro.a2a.envelope.public_key": "...",
"tenzro.a2a.envelope.signature": "...",
"tenzro.a2a.envelope.nonce": "...",
"tenzro.a2a.envelope.timestamp": 1790000000000u64
}
}
}
});
let body = serde_json::to_vec(&request)?;
let conn = endpoint.connect(remote, A2A_ALPN).await?;
let (mut send, mut recv) = conn.open_bi().await?;
send.write_all(&(body.len() as u32).to_le_bytes()).await?;
send.write_all(&body).await?;
send.finish()?;
let mut len = [0u8; 4];
recv.read_exact(&mut len).await?;
let mut resp = vec![0u8; u32::from_le_bytes(len) as usize];
recv.read_exact(&mut resp).await?;
conn.close(0u32.into(), b"done");
let reply: serde_json::Value = serde_json::from_slice(&resp)?;
println!("{}", serde_json::to_string_pretty(&reply)?);
Ok(())
}Build the envelope exactly as in Use the A2A protocol: the signed preimage is tenzro:a2a:message/send::{sender_did}:{nonce_hex}:{timestamp_ms} for a new task. The responder checks it the same way whichever transport the call arrived on.
Expected output: a JSON-RPC result holding the task, with its id, status.state and a response artifact.
5. Read the task from the other transport
Because both transports share one task manager, you can follow the task you just created over HTTPS on the responder:
curl -s http://RESPONDER_HOST:3002/a2a -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tasks/get","params":{"id":"<task-id>"}}' | jq .result.statusPort 3002 is the node's A2A listener.
Next steps
- Use the A2A protocol: cards, envelopes and streaming.
- iroh: the node's iroh endpoint, blobs and discovery.
- A2A protocol reference.