Skip to content

Front a peer agent

Put an A2A agent behind the gateway at /a2a/<name>. The agents calling it then present the credentials this deployment accepts, the rules you write decide which methods reach it, budgets meter it, and guardrails read what is said to it and what it says back. That is the same governance an MCP server gets.

An A2A (Agent2Agent) agent is a peer: something that takes a message, runs a task and answers with messages and artifacts, over a JSON-RPC binding much like MCP’s. The gateway fronts that binding the way it fronts an MCP server, as a passthrough that inspects and forwards bytes. The vocabulary differs. A peer has methods over tasks rather than tools, and message parts rather than arguments, so the rules and the segments have their own shapes, described here.

You will add the agent under a2a, point clients at the gateway’s address for it, and write rules at: [a2a].

Know the agent’s JSON-RPC interface URL. Its card lists it under supportedInterfaces with protocolBinding: JSONRPC. The gateway speaks protocol version 1.0 and fronts that binding only. The gRPC and HTTP+JSON bindings some agents also offer are not proxied. If the agent needs a credential, decide which of the deployment’s credential kinds it takes: a static token, a client_credentials grant, or a token_exchange that lets the agent see the caller rather than the gateway.

Add this configuration:

a2a:
- name: planner
url: https://planner.internal/a2a
credential: planner-as-user # or api_key: ${secret:PLANNER_TOKEN}
cost_per_call: 2

The agent is now reachable at https://<public_url>/a2a/planner. A client sends its JSON-RPC requests there with the credential this deployment accepts, either a virtual key or a token from a bound issuer. It also sends the A2A-Version: 1.0 header the protocol requires of it. The gateway strips that credential, sends the configured one, and forwards the request byte for byte.

A client that discovers agents by their cards finds this address on its own. See the next section.

An A2A client discovers an agent by its card, and a card says where the agent is and how to authenticate to it. Behind the gateway both of those are the gateway’s to say, so the gateway projects cards rather than storing them. It fetches the agent’s own card from the well-known path at its URL’s origin (or from card_url, when the card lives elsewhere), rewrites what is now its own, signs the result, and serves it at:

https://<public_url>/a2a/planner/.well-known/agent-card.json

The projection changes these fields and nothing else:

  • supportedInterfaces becomes the one interface the gateway fronts, the JSON-RPC binding at the address above. A gRPC or HTTP+JSON interface the agent also offers is dropped rather than advertised at an address nothing governs.
  • securitySchemes and securityRequirements become what this deployment accepts: an OpenID Connect scheme per issuer bound under auth.issuers, pointing at that issuer’s discovery document, and a bearer scheme when virtual keys are accepted. The agent’s own schemes describe a credential the client will never present.
  • capabilities.pushNotifications becomes false, because the door refuses those methods.
  • The agent’s own signatures are dropped, because they cover a document this is not.

The card is cached on the upstream’s own max-age and revalidated with its ETag. A change to public_url, to what auth accepts or to the signing keys invalidates it on the next read. The authenticated extended card, asked for through the door with GetExtendedAgentCard, is projected the same way on its way back.

To sign the cards, give the deployment a key:

a2a_cards:
signing_keys:
- ${secret:a2a-card-key} # an EC P-256 private key, PEM

Run this command:

Terminal window
$ openssl ecparam -genkey -name prime256v1 -noout -out card-key.pem
$ pistra secrets put a2a-card-key < card-key.pem # or PUT /admin/v1/secrets/a2a-card-key

GET /admin/v1/a2a/<name>/card fetches the agent’s card and returns the projection. That is both the reachability check and a look at what a client will discover. The console’s A2A agents page shows the same behind its card button. GET /admin/v1/a2a lists the agents with their doors and where each card comes from.

Signing follows the specification. The card without its signatures is canonicalized (RFC 8785) and signed with ES256. The protected header carries alg, typ: JOSE, the key’s RFC 7638 thumbprint as kid, and a jku naming https://<public_url>/.well-known/a2a-jwks.json, where every configured key is published. The first key signs and all are published. To rotate, add the new key first, wait out the cache, then drop the old one. Without the block, cards are served unsigned, and a client that insists on a signature will refuse them.

Access rules at: [a2a] are evaluated deployment-wide first and then in the caller’s profile, as rules at the MCP hop are. The variables are the caller’s (key, agent, user) and the call’s:

variable
peer the configured agent name
method the JSON-RPC method: SendMessage, SendStreamingMessage, GetTask, ListTasks, CancelTask, SubscribeToTask, GetExtendedAgentCard
role the message’s role on the two message methods, ROLE_USER or ROLE_AGENT
task_id, context_id the task and conversation the request names, where the method carries them
parts the message’s parts, one map each with kind (text, file, data), media_type, url and filename

parts says what a part is, never what it says. Guardrails read the content. parts is parsed from the body only when some rule mentions it.

access_rules:
- name: no-cancelling-the-planner
at: [a2a]
condition: peer == "planner" && method == "CancelTask"
action: deny
- name: no-files-to-external-agents
at: [a2a]
condition: peer.startsWith("ext-") && parts.exists(p, p.kind == "file")
action: deny
message: external agents take text only
- name: no-cancelling-anything
at: [mcp, a2a]
condition: method == "CancelTask" || method.endsWith("/cancel")
action: deny

A rule at two hops reads what both declare. method belongs to the MCP hop and to the A2A hop, and the rule is compiled against each. Check a condition before it ships with the rule suite (pistra -test-rules), whose access-rule cases take at: a2a. You can also check it with POST /admin/v1/config/check-condition under the scope access_rules with "at": ["a2a"].

Every detector and rule configured under guardrails applies to the door unchanged. The route label is a2a, and server carries the agent’s name for a rule that narrows by it. On the way out the gateway reads the message’s parts: each text part, each file part’s URL and filename, and each data part leaf by leaf. Bytes carried inline are not read, because a base64 blob is not text. On the way back it reads what the agent produced: the parts of a message, of a task’s status message, artifacts and history, and of each streamed event, along with an error’s message. A streamed response is judged one event at a time, each frame a whole JSON-RPC message.

An agent inside the boundary that needs real values takes the same restore list as an MCP server. The argument is the same, because the gateway dials this hop.

  • A 0.3 client. The specification makes the A2A-Version header mandatory and says an empty one means 0.3. Both are refused with the protocol’s own VersionNotSupportedError naming 1.0. The gateway does not guess at a shape it does not know.
  • Push notifications. The four push-configuration methods are refused with PushNotificationNotSupportedError. An agent that supports them posts task updates to a webhook the client named, directly, around the gateway.
  • A method not in 1.0. The method set is closed and policy is written over it, so an unknown method is refused rather than forwarded.

Everything else a gateway refuses (a missing credential, a rule, a budget, a suspension as a2a:<name>) answers the way it does at the MCP door. The answer is a JSON-RPC error whose data.code is the bounded reason.