← back to projects

a2a-rust

Pure Rust SDK for the Agent-to-Agent protocol

lang Rust license Apache-2.0 view on GitHub →

Highlights

  • Full A2A v1.0.0 wire types across all four transports (JSON-RPC 2.0, REST, WebSocket, gRPC)
  • Server-Sent Events streaming and pluggable push notifications
  • Object-safe AgentExecutor trait with no Pin<Box<dyn Future>> boilerplate
  • Multi-tenancy with pluggable storage backends (in-memory, SQLite, PostgreSQL)
  • Body limits, content validation, SSRF protection, CORS, rate limiting
  • Mutation-tested in CI with a zero-surviving-mutants gate
  • Listed in the official A2A project community directory

Status

a2a-rust is listed as a Community SDK on the official A2A protocol site at v1.0.0 specification parity. A2A is an early-stage protocol and the Rust-agent ecosystem is still small, so the directory listing is a signal that the SDK was built carefully enough for the people running the protocol to take seriously, not a prize.

Background

The Agent-to-Agent (A2A) protocol is an open specification for letting autonomous AI agents discover one another, exchange structured messages, and stream long-running results regardless of framework, language, or vendor. It moved under the Linux Foundation in 2025, and it sits conceptually right next to the Model Context Protocol (MCP). Where MCP is about an agent talking to tools, A2A is about agents talking to each other.

When I started the project, the A2A organization shipped official SDKs for Python, Go, Java, JavaScript, and C#/.NET, but not Rust. For teams that wanted to deploy agents as small, auditable, single-binary services, the absence was a blocker. a2a-rust closes the gap with a pure Rust implementation built to the same engineering bar as the official SDKs.

What it does

The SDK implements every surface of the A2A v1.0.0 specification and exposes it through an ergonomic server framework. You bring the agent logic; the SDK handles transport, storage, authentication, rate limiting, streaming, and observability.

On the transport side it speaks all four variants the specification defines (JSON-RPC 2.0, REST, WebSocket, and gRPC) behind a single server abstraction, so the same agent can be exposed across multiple transports from one binary. Server-Sent Events handle streaming responses for long-running tasks, and pluggable push notifications deliver completion callbacks without requiring the client to hold an open connection.

Most of the design work went into the framework level. Interceptor chains let you compose middleware (auth, logging, metrics, quota enforcement, content validation) the way a mature web stack would. The storage layer is pluggable and ships with in-memory, SQLite, and PostgreSQL backends, so a prototype can graduate to production without a rewrite. The AgentExecutor trait is deliberately object-safe, which removes the Pin<Box<dyn Future>> boilerplate that plagues async traits in Rust and makes the developer experience feel closer to Go or Python.

The executor trait

The AgentExecutor trait is object-safe and async, and its surface is deliberately narrow: one call to handle an incoming message, one call to emit a response (streaming or otherwise), and a hook for intermediate events. Anything more ambitious belongs in an interceptor, not in the executor itself.

A tool-using executor typically wants to receive the A2A message, assemble a model call with tool definitions and a stable system prompt, invoke the model, run any requested tools, loop back into the model, stream intermediate events to the A2A caller, and return a final answer. Every one of those steps maps cleanly onto the trait surface without a wrapper crate or shim.

A condensed tool-using executor built on a2a-rust:

use a2a_rust::{AgentExecutor, AgentRequest, AgentResponse, Event, EventSink};
use async_trait::async_trait;

pub struct ToolUseExecutor {
    model: ModelClient,
    tools: Vec<Tool>,
    system_prompt: String, // stable, cacheable across calls
}

#[async_trait]
impl AgentExecutor for ToolUseExecutor {
    async fn handle(
        &self,
        req: AgentRequest,
        events: &mut dyn EventSink,
    ) -> anyhow::Result<AgentResponse> {
        let mut history = vec![Message::user(req.text())];
        let mut steps = 0;
        const MAX_STEPS: usize = 8;

        loop {
            steps += 1;
            if steps > MAX_STEPS {
                return Ok(AgentResponse::refusal(
                    "hit the tool-use step budget without converging",
                ));
            }

            let reply = self
                .model
                .request()
                .system(&self.system_prompt)
                .tools(self.tools.clone())
                .messages(history.clone())
                .send()
                .await?;

            events.emit(Event::model_step(&reply)).await?;

            match reply.stop_reason() {
                StopReason::EndTurn => return Ok(reply.into_response()),
                StopReason::ToolUse => {
                    for tool_use in reply.tool_uses() {
                        let tool_result = self.run_tool(&tool_use).await?;
                        events.emit(Event::tool_call(&tool_use, &tool_result)).await?;
                        history.push(Message::assistant_tool_use(&tool_use));
                        history.push(Message::user_tool_result(&tool_result));
                    }
                }
                other => return Err(anyhow::anyhow!("unexpected stop_reason: {:?}", other)),
            }
        }
    }
}

A few things the example is doing that the trait design enables:

  • The system_prompt is a stable cacheable block, not a per-call assembly. Prompt-caching strategies reward this shape.
  • The tool-use loop has an explicit step budget, and hitting it produces a structured refusal rather than an exception.
  • Every intermediate step (model step, tool call, tool result) is emitted to the A2A caller as an SSE event.
  • The handle signature is unchanged from the SDK default. No wrapper layer, no shim.

The design thesis is that the protocol is the right substrate and the executor trait is the right place for the model-specific code. Separating the two means a deployment can swap models without rewriting the surface the rest of the infrastructure depends on.

Enterprise hardening

Request-body size limits. Content-type validation. SSRF protection on outbound agent calls. Configurable CORS. Rate limiting. A multi-tenancy model built into the storage interface on day one. All of these are in the SDK because agents running inside a real organization will be exposed to untrusted callers, handling regulated data, or both.

Engineering bar, with receipts

Correctness is a hard requirement rather than an aspiration.

  • Zero unsafe blocks. Enforced by #![forbid(unsafe_code)] at every crate root. Grep it: git grep forbid.

  • Technology Compatibility Kit validates behavior against the A2A v1.0.0 specification test vectors on every push.

  • Zero-surviving-mutants gate via cargo-mutants, wired into the CI workflow under .github/workflows/ in the repository. A PR that introduces a mutant the tests cannot kill fails the gate; it doesn’t merge.

  • Per-transport benchmarks under benches/ fail CI on regression, so “quiet production slowdown” stops being a class of bug the SDK can ship.

  • Reproducible locally in one command:

    git clone https://github.com/tomtom215/a2a-rust
    cd a2a-rust
    cargo test --all-features && cargo bench

The point of listing these individually is that “enterprise hardening” is a claim any README can make. The CI workflow, benchmark sources, and mutation-testing gate are the only way to actually believe it, so I’d rather you look at them than take my word.

Why this project exists

A2A sits at the intersection of the architectural rigor a serious wire format demands and the agent-infrastructure space. Shipping a Rust SDK at the quality bar the specification deserves, and having it picked up by the A2A project’s community directory, is the work the project is for.