Skip to content
Tenzro
← All tutorials
Tutorial · Agents

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.

Advanced25 min

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 tenzro CLI on both.
  • Rust with iroh, tokio, serde_json and anyhow for 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:

bash
tenzro iroh info
tenzro iroh alpns

tenzro iroh info calls tenzro_iroh_getInfo and returns, among other fields:

json
{
  "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:

bash
curl -s https://rpc.tenzro.xyz \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tenzro_nodeDidDocument","params":{}}' | jq

Call 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_bytes

The 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.

rust
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:

bash
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.status

Port 3002 is the node's A2A listener.

Next steps