Skip to content

Integrate with A2A

The Agent2Agent (A2A) protocol is an open standard for conversations between AI agents. Where a REST call says "fetch this resource," an A2A exchange says "here is a message; work on it and emit artifacts as you go." Agents discover each other's capabilities through a signed Agent Card, communicate over JSON-RPC, and stream results back as structured events.

certified fits into A2A at the transport layer: it wraps every connection in mutual TLS so each side knows the verified identity of the other before a single A2A message is exchanged.


Why mTLS matters for A2A

In a standard A2A deployment, authentication is described in the Agent Card's security_schemes field. Without mTLS the common choices are API keys or OAuth bearer tokens, which authenticate the request but leave the connection unverified. A compromised intermediary can relay requests with a stolen token.

mTLS authenticates the connection. The client's x509 certificate is verified against the server's known_clients/ trust store during the TLS handshake — before any HTTP bytes flow. The server knows it is talking to a specific entity with a specific key, not just someone who holds a token string.

Agent Card: mTLS only

{
  "name": "compute-agent",
  "version": "1.0.0",
  "supported_interfaces": [
    { "protocol_binding": "jsonrpc", "url": "https://compute.ornl.gov:8443" }
  ],
  "security_schemes": {
    "mtls": {
      "type": "mutualTLS",
      "description": "Client must present a certificate signed by the ORNL CA."
    }
  },
  "security_requirements": [{ "mtls": [] }]
}

Agent Card: mTLS transport + biscuit authorisation

Layering a biscuit token on top adds authorisation without giving up the identity guarantee that mTLS provides. The certified two-layer model maps cleanly:

Layer Mechanism Answers
Transport x509 mTLS Who are you?
Request Biscuit in Bearer: header What may you do?
{
  "name": "compute-agent",
  "version": "1.0.0",
  "supported_interfaces": [
    { "protocol_binding": "jsonrpc", "url": "https://compute.ornl.gov:8443" }
  ],
  "security_schemes": {
    "mtls": {
      "type": "mutualTLS",
      "description": "Client must present a certificate signed by the ORNL CA."
    },
    "biscuit": {
      "type": "http",
      "scheme": "bearer",
      "bearerFormat": "biscuit",
      "description": "Biscuit token issued by the ORNL CA, passed in the Bearer header."
    }
  },
  "security_requirements": [{ "mtls": [], "biscuit": [] }]
}

Clients that pass mTLS but lack a valid biscuit are authenticated (the server knows who they are) but not authorised to act. See Authorization Model for how to issue and validate biscuit tokens.


Server example

Full source: examples/a2a/server.py in the repository.

# install deps (not in pyproject.toml)
uv pip install "a2a-sdk[fastapi]"

python examples/a2a/server.py

Wrapping an A2A app with mTLS

cert.serve() accepts any ASGI app, including A2A's FastAPI application:

from certified import Certified
from fastapi import FastAPI

cert = Certified()
app = build_app()          # returns an A2A FastAPI app
cert.serve(app, BASE_URL)  # uvicorn + mTLS, blocks until interrupted

Incoming connections must present a certificate trusted by the server's known_clients/ directory. Connections that fail the handshake are dropped by the TLS layer before the ASGI app is reached.

Reading the peer identity

certified's uvicorn monkey-patch threads the TLS transport object into every request's ASGI scope. A2A servers receive requests via Starlette's Request object, so peer identity is one call away:

def _extract_peer_cn(request: Request) -> str | None:
    transport = request.scope.get("transport")
    if transport is None:
        return None
    peercert = transport.get_extra_info("peercert")
    if not peercert:
        return None
    for field, value in peercert.get("subject", ()):
        if field == "commonName":
            return value
    return None

Wire this into A2A's ServerCallContextBuilder to populate ServerCallContext.user:

class CertifiedContextBuilder(ServerCallContextBuilder):
    def build(self, request: Request) -> ServerCallContext:
        cn = _extract_peer_cn(request)
        user = PeerCertUser(cn) if cn else UnauthenticatedUser()
        return ServerCallContext(user=user, state={"headers": dict(request.headers)})

Inside any AgentExecutor, the caller's identity is then available as:

async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
    caller = context.call_context.user.user_name  # the peer cert CN

Client example

Full source: examples/a2a/client.py in the repository.

# install deps (not in pyproject.toml)
uv pip install "a2a-sdk[fastapi]" httpx

# register the server once
certified add-service echo-agent https://127.0.0.1:8443

python examples/a2a/client.py "Hello, agent!"

Wiring mTLS into the A2A SDK

A2A's ClientFactory accepts a pre-built httpx.AsyncClient. There are two patterns depending on who controls the client lifecycle.

Pattern 1 — you control the lifecycle (cert.AsyncClient)

Use this for simple async calls where you open a session, make requests, and close it yourself:

async with cert.AsyncClient("https://echo-agent") as http:
    r = await http.get("/echo/ping")
    r.raise_for_status()

Pattern 2 — the framework controls the lifecycle (cert.ssl_context)

Use this when you need to hand a pre-configured httpx.AsyncClient to a library. A2A's ClientFactory owns the client's lifetime, so pass the raw client:

srv = cert.lookup_server("echo-agent")
ssl_ctx = cert.ssl_context(is_client=True, srv=srv)   # mTLS context for this peer
httpx_client = httpx.AsyncClient(verify=ssl_ctx)       # httpx client, unmanaged

factory = ClientFactory(ClientConfig(httpx_client=httpx_client))
client = await factory.create_from_url(srv.url)        # fetches agent card, negotiates transport

create_from_url fetches /.well-known/agent-card.json over the already-authenticated mTLS connection and negotiates the transport protocol from the card. Everything after that is standard A2A:

request = SendMessageRequest(
    message=Message(role="ROLE_USER", parts=[Part(text=prompt)])
)
async with client:
    async for event in client.send_message(request):
        text = get_stream_response_text(event)
        if text:
            print(text)

Summary

Concern How certified handles it
Server mTLS cert.serve(a2a_app, url) — one line
Peer identity on server request.scope['transport'].get_extra_info('peercert')
Client mTLS (simple) cert.AsyncClient("https://peer")
Client mTLS (framework) httpx.AsyncClient(verify=cert.ssl_context(True, srv))
Alias resolution cert.lookup_server(name) reads known_servers/<name>.yaml
Authorisation layer Biscuit token in Bearer: header — see Authorization Model