Agent Cards & Discovery

An Agent Card is the machine-readable discovery document that describes an A2A agent. Clients fetch the card to learn what the agent can do, how to connect, and what security is required.

The Agent Card

Agent cards are served at /.well-known/agent-card.json and contain:

{
  "name": "Calculator Agent",
  "description": "Evaluates arithmetic expressions",
  "version": "1.0.0",
  "supportedInterfaces": [
    {
      "url": "https://agent.example.com/rpc",
      "protocolBinding": "JSONRPC",
      "protocolVersion": "1.0.0"
    },
    {
      "url": "https://agent.example.com/api",
      "protocolBinding": "HTTP+JSON",
      "protocolVersion": "1.0.0"
    }
  ],
  "defaultInputModes": ["text/plain"],
  "defaultOutputModes": ["text/plain"],
  "skills": [
    {
      "id": "calc",
      "name": "Calculator",
      "description": "Evaluates expressions like '3 + 5'",
      "tags": ["math", "calculator"],
      "examples": ["3 + 5", "10 * 2"]
    }
  ],
  "capabilities": {
    "streaming": true,
    "pushNotifications": false
  }
}

Naming a protocol binding

protocolBinding names the transport a client should dial. The spec's canonical values are "JSONRPC", "GRPC" and "HTTP+JSON" (the AgentInterface.protocolBinding field, §4.4.6; see the §8.5 sample card), and any binding outside those three is a custom binding (§12).

Custom bindings should be identified by a URI rather than a bare word (§5.8). A bare name is unique only by convention: two projects can define incompatible bindings called "WEBSOCKET" and nothing in the card tells a client which one it is looking at. A URI under a domain the binding's author controls can only mean one thing. It is an identifier, not a document — it does not have to resolve.

The two custom bindings in this project both follow that rule:

BindingprotocolBindingConstant
WebSockethttps://a2a-rust.com/bindings/websocket/v1a2a_protocol_types::WEBSOCKET_BINDING_URI
SLIMRPChttps://a2a-protocol.org/bindings/experimental-slimrpc/v1a2a_protocol_slimrpc::SLIMRPC_PROTOCOL_BINDING

The trailing /v1 carries the binding's version, because §5.8 asks for a new URI on a breaking change rather than a redefinition of the old one.

Before 0.11.0 this project advertised the bare "WEBSOCKET". A client reading cards in the wild should accept both spellings for as long as the old one is still out there.

Required Fields

FieldDescription
nameHuman-readable agent name
descriptionWhat the agent does
versionSemantic version of this agent
supportedInterfacesAt least one interface with URL and protocol binding
defaultInputModesMIME types accepted (e.g., ["text/plain"])
defaultOutputModesMIME types produced
skillsAt least one skill describing a capability
capabilitiesCapability flags (streaming, push, etc.)

Skills

Skills describe discrete capabilities:

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::types::agent_card::AgentSkill;

let skill = AgentSkill {
    id: "summarize".into(),
    name: "Summarizer".into(),
    description: "Summarizes long documents".into(),
    tags: vec!["nlp".into(), "summarization".into()],
    examples: Some(vec![
        "Summarize this research paper".into(),
        "Give me a 3-sentence summary".into(),
    ]),
    input_modes: Some(vec!["text/plain".into(), "application/pdf".into()]),
    output_modes: Some(vec!["text/plain".into()]),
    security_requirements: None,
};
}

Skills can override the agent's default input/output modes and declare their own security requirements.

Capabilities

The AgentCapabilities struct advertises what the agent supports:

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::AgentCapabilities;

let caps = AgentCapabilities::none()
    .with_streaming(true)           // Supports SendStreamingMessage
    .with_push_notifications(true)  // Supports push notification configs
    .with_extended_agent_card(true); // Has an authenticated extended card
}

Note: AgentCapabilities is #[non_exhaustive] — always construct it via AgentCapabilities::none() and the builder methods, never with a struct literal.

The server enforces these flags (spec §3.3.4). When you configure an agent card on the handler (RequestHandlerBuilder::with_agent_card), the declared capabilities become a contract the server honors:

  • If streaming is not true, SendStreamingMessage and SubscribeToTask return UnsupportedOperationError.
  • If pushNotifications is not true, the push-config operations (Create/Get/List/Delete) return PushNotificationNotSupportedError.
  • If extendedAgentCard is not true, GetExtendedAgentCard returns UnsupportedOperationError.

So if your agent serves streaming or push, set the matching flag to true — otherwise clients are told the operation is unsupported. A handler with no agent card configured publishes no contract and is not gated.

Interfaces

Each interface describes a transport endpoint:

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::types::agent_card::AgentInterface;

let interface = AgentInterface {
    url: "https://agent.example.com/rpc".into(),
    protocol_binding: "JSONRPC".into(),  // or "HTTP+JSON", "GRPC"
    protocol_version: "1.0.0".into(),
    tenant: None, // Optional: fixed tenant for this interface
};
}

For HTTP-based bindings url is an absolute URL (HTTPS in production). For the gRPC binding it is a gRPC target, "grpc.example.com:443" — no scheme, because gRPC names have none; whether the channel uses TLS is the client's decision (see gRPC).

An agent must have at least one interface. Having multiple interfaces (e.g., JSON-RPC and REST) lets clients choose their preferred transport.

The choice is the client's, not the card's. ClientBuilder::from_card() walks ClientConfig::preferred_bindings in order and takes the first binding the card offers, falling back to the card's first interface only when it offers none of them. Pass your own order with ClientBuilder::from_card_preferring(&card, &["GRPC".into()]).

Because a card gives each binding its own URL, the endpoint follows the binding: selecting GRPC selects that interface's url and tenant as well. The tenant field from the selected interface is preserved in ClientConfig::tenant and sent on every request — all eleven methods, GetExtendedAgentCard included — as spec §8.3.2 rule 4 requires. Methods whose params carry a tenant (send, get, list, push-config) let a per-request value override it.

Extended Agent Card

An agent can expose a richer card via GetExtendedAgentCard for authenticated clients. This requires setting capabilities.extended_agent_card = true:

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;
let capabilities = AgentCapabilities::none()
    .with_extended_agent_card(true);
}

If the capability is false or absent, the server returns UnsupportedOperationError. ExtendedAgentCardNotConfiguredError is returned when the handler has no agent card at all.

The operation must be authenticated (§13.3): without an authenticating interceptor (e.g. BearerTokenAuthInterceptor, JwtAuthInterceptor) the server refuses it with InvalidRequest, unless you opt in with RequestHandlerBuilder::allow_unauthenticated_extended_card().

Serving Agent Cards

Static Handler

For agent cards that don't change at runtime:

use a2a_protocol_sdk::prelude::*;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
fn make_agent_card() -> AgentCard { AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000")) }
fn main() {
let my_executor = MyAgent;
use a2a_protocol_sdk::server::RequestHandlerBuilder;

let handler = RequestHandlerBuilder::new(my_executor)
    .with_agent_card(make_agent_card())
    .build()
    .unwrap();
}

The static handler automatically provides:

  • ETag headers for cache validation
  • Last-Modified timestamps
  • Cache-Control directives
  • 304 Not Modified responses for conditional requests

Dynamic Handler

For agent cards that change (e.g., based on feature flags, load, or authentication):

#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use a2a_protocol_sdk::prelude::{A2aResult, AgentInterface};
fn make_agent_card() -> AgentCard { AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000")) }
use a2a_protocol_sdk::server::{AgentCardProducer, DynamicAgentCardHandler};
use a2a_protocol_sdk::types::agent_card::AgentCard;

struct MyCardProducer;

impl AgentCardProducer for MyCardProducer {
    fn produce<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<AgentCard>> + Send + 'a>> {
        Box::pin(async move {
            // Generate card dynamically
            Ok(make_agent_card())
        })
    }
}
}

The dynamic handler calls the producer on every request, computes a fresh ETag, and handles conditional caching.

Hot-Reload Handler

For agent cards loaded from a JSON file that may change at runtime:

use a2a_protocol_sdk::prelude::*;
fn make_agent_card() -> AgentCard { AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000")) }
#[tokio::main]
async fn main() {
let initial_card = make_agent_card();
use a2a_protocol_sdk::server::HotReloadAgentCardHandler;
use std::path::Path;
use std::time::Duration;

let handler = HotReloadAgentCardHandler::new(initial_card);

// Cross-platform: poll the file every 30 seconds
let watcher = handler.spawn_poll_watcher(
    Path::new("/etc/a2a/agent.json"),
    Duration::from_secs(30),
);

// Unix only: reload on SIGHUP
#[cfg(unix)]
let signal_watcher = handler.spawn_signal_watcher(
    Path::new("/etc/a2a/agent.json"),
);
}

HotReloadAgentCardHandler implements AgentCardProducer, so it plugs directly into DynamicAgentCardHandler for full HTTP caching support. The internal Arc<RwLock<AgentCard>> ensures updates are atomic with low contention for concurrent readers.

HTTP Caching

Agent card responses include standard HTTP caching headers (RFC 7232):

HeaderPurpose
ETagContent hash for cache validation
Last-ModifiedTimestamp of last change
Cache-Controlpublic, max-age=3600 (configurable)

Clients should send If-None-Match or If-Modified-Since headers. If the card hasn't changed, the server returns 304 Not Modified with no body.

Security

Agent cards declare security schemes and the requirements that reference them. This is the v1.0 wire form, which is what this SDK emits:

{
  "securitySchemes": {
    "bearer": { "httpAuthSecurityScheme": { "scheme": "bearer" } },
    "oauth": { "oauth2SecurityScheme": { "flows": { "clientCredentials": {
      "tokenUrl": "https://auth.example.com/token", "scopes": { "read": "Read" } } } } }
  },
  "securityRequirements": [
    { "schemes": { "bearer": { "list": [] } } },
    { "schemes": { "oauth": { "list": ["read"] } } }
  ]
}

Each scope list is a StringList object ({"list": [...]}), because the spec defines the JSON as the ProtoJSON of map<string, StringList>. When reading a card, a bare array ("oauth": ["read"], as a2a-go v2.5.0 writes it) and null are accepted too, so a Go agent's card parses; only the {"list": [...]} form is ever written.

Individual skills can also declare their own security requirements, overriding the global ones.

Extended Agent Cards

If the agent supports extendedAgentCard capability, clients can fetch an authenticated version with additional details via the GetExtendedAgentCard method.

Next Steps