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].
Before you start
Section titled “Before you start”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.
Declare the agent
Section titled “Declare the agent”Add this configuration:
a2a: - name: planner url: https://planner.internal/a2a credential: planner-as-user # or api_key: ${secret:PLANNER_TOKEN} cost_per_call: 2The 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.
Publish the agent’s card
Section titled “Publish the agent’s card”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.jsonThe projection changes these fields and nothing else:
supportedInterfacesbecomes 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.securitySchemesandsecurityRequirementsbecome what this deployment accepts: an OpenID Connect scheme per issuer bound underauth.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.pushNotificationsbecomes false, because the door refuses those methods.- The agent’s own
signaturesare 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, PEMRun this command:
$ 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-keyGET /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.
Write rules over the call
Section titled “Write rules over the call”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: denyA 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"].
What guardrails read
Section titled “What guardrails read”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.
What the door refuses
Section titled “What the door refuses”- A 0.3 client. The specification makes the
A2A-Versionheader mandatory and says an empty one means 0.3. Both are refused with the protocol’s ownVersionNotSupportedErrornaming 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.