Govern peer-agent calls with the A2A Gateway
An agent can delegate work to another agent, but that delegation should not bypass identity, policy, data protection, or human review. The DVARA A2A Gateway governs each peer-agent call as a distinct, traceable hop.
Not included in DVARA Open Source.
How does DVARA govern a peer-agent call?
Your application sends one A2A request to DVARA. DVARA:
- authenticates the API key and resolves its workspace;
- finds the target agent in that workspace's registry;
- records
A2A_HOP_INTENTbefore the peer is contacted; - evaluates matching A2A policy and scans the outbound message for PII;
- checks the session for a repeated hop, cycle, or excessive rate;
- applies any matching human-approval rule on a synchronous hop;
- authenticates to the peer and forwards an allowed request; and
- scans the reply, records the outcome, and returns it to the caller.
DVARA governs one hop. It does not choose the next agent, make an onward call, or run your multi-agent workflow. Your application remains the orchestrator.
What do you need before you send a hop?
You need a running Enterprise platform deployment (a licence is optional), an active workspace API key, and an active target agent registered in that same workspace.
Register the peer from Agents → A2A Agents in DVARA Flightdeck. Give it a stable agent ID, its endpoint URL, an authentication scheme, and any skills that the workspace may discover. Workspace-supplied endpoint URLs are checked before they are saved; loopback, private, link-local, and cloud-metadata addresses are refused.
From 1.8.5 a peer's endpoint must be https://, as the A2A specification
requires. A hop carries the workspace's stored credential or the caller's
delegated token, and over http:// both would cross the network in clear text.
The Flightdeck, the Flightdeck and a GitOps import refuse an active agent whose
endpoint is not https://, with A2A_ENDPOINT_NOT_HTTPS. The gateway refuses
to send a hop, open a stream or fetch an Agent Card for such an agent, and never
contacts the peer: message:send and task calls answer
502 A2A_ENDPOINT_NOT_HTTPS, and a stream answers one error event,
a2a_endpoint_not_https.
An agent saved over http:// before 1.8.5 is not changed. Flightdeck names
each active one in a warning at startup, and the Flightdeck agent lists
mark it Not HTTPS. Every hop to it is refused until you move it to
https:// or disable it. A disabled or suspended agent may keep its http://
endpoint, because the gateway sends it nothing.
For a local stack whose stub peers have no certificate, set
DVARA_A2A_ALLOW_PLAINTEXT_ENDPOINTS=true on Flightdeck and the Gateway. It
works only on a development profile (dev, test, ci, local, or none
set). On any other profile, such as the production profile the Compose file
and the Helm chart set, it is ignored and a warning at startup says so.
DVARA does not follow redirects from an upstream A2A agent. Register the final
URL: a 3xx response fails the governed hop, health check, or Agent Card
discovery instead of being followed to another address.
From 1.8.4 an agent ID cannot contain ::. The Flightdeck, the Flightdeck and a GitOps
import refuse such a new ID as A2A_AGENT_ID_INVALID; an agent that already has
one keeps it. An ID the workspace already uses is refused on the form as
A2A_AGENT_DUPLICATE, naming the ID, and the values you entered stay in the
form.
Send a governed hop
Replace <dvara-host> and <your-api-key>. This example calls the registered
researcher agent and supplies an explicit skill for policy and approval
matching.
curl --silent https://<dvara-host>/a2a/researcher/message:send \
--header 'Authorization: Bearer <your-api-key>' \
--header 'Content-Type: application/json' \
--header 'X-Session-Id: case-1042' \
--data '{
"jsonrpc": "2.0",
"id": "hop-1042",
"method": "message/send",
"params": {
"metadata": { "skill": "summarize" },
"message": {
"role": "user",
"parts": [
{
"kind": "text",
"text": "Summarize the approved incident report for the support team."
}
]
}
}
}'
An allowed peer response stays inside the JSON-RPC envelope. The exact task fields come from the peer; a minimal result has this shape:
{
"jsonrpc": "2.0",
"id": "hop-1042",
"result": {
"task": {
"id": "task-7f12"
}
}
}
The response also includes X-Trace-Id. Use it with X-Session-Id to follow
the hop through DVARA Flightdeck and your own service logs.
Verify what DVARA recorded
Open Audit in Flightdeck, select the A2A plane, and find case-1042. The
record shows the workspace, target agent, requested skill, operation, policy
decision, authentication mode, status, latency, and PII flags.
The A2A audit chain writes A2A_HOP_INTENT before dispatch. An allowed response
writes A2A_HOP_RESULT; a governance refusal writes A2A_HOP_DENIED. The A2A
chain is HMAC-signed and hash-chained, so changes or gaps are detectable. The
guarantee is tamper-evident.
From 1.8.2 a Gateway pod has no database, so this separate A2A chain is signed into the pod's local spool and drained to Flightdeck. It does not silently disappear on the way.
What happens without extra configuration?
| Control | 1.8 default | What that means |
|---|---|---|
| API-key authentication | Required | Anonymous A2A calls are refused |
| API-key scopes | Empty means unrestricted | A scoped key needs a2a:send for messages and cancellation, or a2a:read for task reads |
| A2A policy | Default allow | The first matching ALLOW or DENY rule decides; no match allows the hop |
| PII action | LOG unless the workspace overrides it | Detection is recorded and the original content continues |
| Delegation guard | enforce, rule agent-skill, depth 8 (from 1.8.3) | A hop that repeats the same agent and skill in its chain, or makes the chain longer than 8, is refused with 409. See the delegation guard |
| Loop detection | On for hops with a session ID | Fixed 1.8 thresholds detect repetition, short cycles, and more than 60 hops per minute |
| Loop response | Kill the session | The detected hop and later hops in that session are refused. From 1.8.5 the hop rate refuses with 429 and does not kill the session |
| Peer response size | 16 MiB for a whole answer, 64 MiB for a stream (from 1.8.5) | A larger answer is refused with 502 A2A_RESPONSE_TOO_LARGE. See Response size |
| Push notifications | Through the gateway (from 1.8.5) | Needs A2A_PUSH_CALLBACK_BASE_URL; without it a push config is refused. See Receive push notifications through DVARA |
| Approval engine | On, with no matching rules | A synchronous hop does not pause until the workspace names required agents or skills |
| Approval timeout | 300 seconds, then deny | A matching synchronous hop fails closed when nobody decides |
| Signed-card requirement | Off | Discovery serves a valid card with its trust state unless strict verification is enabled |
A suspended workspace is refused. A key with a nonempty scope list can perform
only operations covered by those scopes. Agent Card discovery still requires a
valid key, but it is not narrowed by a2a:read in 1.8.
Make skill policy match the request
A2A v0.3 has no first-class skill field on message/send. DVARA reads the first
nonempty value from:
params.metadata.skill;params.message.metadata.skill; orparams.skill.
Use params.metadata.skill, as in the example. If the request carries no skill,
agent-scoped rules can still match, but skill-scoped rules and approval patterns
cannot.
A2A policies are separate from LLM and MCP policies. They match source,
target, and skill, use first-match-wins for ALLOW and DENY, and accumulate
matching WARN rules. A policy denial returns HTTP 403 with
A2A_POLICY_DENIED. An evaluation failure also fails closed with HTTP 403 and
A2A_POLICY_ERROR.
Choose how PII is handled
DVARA scans text parts before dispatch and scans a successful peer reply before
release. The workspace's PII action decides what happens. Its default is LOG.
| Action | Outbound message | Peer response |
|---|---|---|
LOG | Record detection and forward the original text | Record detection and release the original reply |
REDACT | Replace detected values before dispatch | Replace newly detected values before release |
TOKENIZE | Replace provider-bound values with recoverable tokens when tokenization is available | Irreversibly redact newly detected response values |
BLOCK | Return A2A_PII_DETECTED; do not contact the peer | Return A2A_PII_DETECTED; do not release the reply |
If PII enforcement itself fails on content that requires protection, DVARA
fails closed with A2A_PII_ENFORCEMENT_FAILED.
Require approval for a risky hop
Set approval.required-agents to exact target-agent IDs or
approval.required-skills to skill globs in workspace settings. For example,
require a person before an agent delegates a refund:
approval.required-agents: "payments"
approval.required-skills: "refund_*,issue_credit"
approval.timeout-seconds: 300
approval.default-action: "deny"
A match writes A2A_APPROVAL_REQUESTED and pauses a synchronous
message:send. Approving releases the hop; denying returns HTTP 403 with
A2A_APPROVAL_DENIED. The default timeout returns HTTP 408 with
A2A_APPROVAL_TIMEOUT. If the approval service is unavailable, DVARA applies
the configured default immediately and records A2A_APPROVAL_UNAVAILABLE.
Keep the default action set to deny unless the risk owner has accepted a
fail-open approval path.
Stop a runaway delegation chain
From 1.8.3, the A2A delegation guard refuses the first hop that loops back or goes too deep, using a signed chain that each agent forwards. The session loop detector below stays on as its backstop.
Send one stable X-Session-Id across the complete workflow. Without it, DVARA
still applies the other controls but cannot join the hops for loop detection.
In 1.8, the detector uses fixed thresholds:
| Pattern | Threshold |
|---|---|
| Same target agent and skill repeated consecutively | 5 hops |
| Cycle containing 2–4 different hops | 3 repetitions |
| Rate within one session | More than 60 hops per minute |
| Retained session history | 100 hops |
A detection returns A2A_LOOP_DETECTED, writes an audit event, and kills that
session. From 1.8.5 the rate rule is different: past 60 hops a minute a hop is
refused with 429 A2A_SESSION_RATE_LIMITED, and the session is not killed,
because that is overload, not a loop.
From 1.8.5 the history is shared by the gateways in one cluster. Two gateways refuse a loop at the same hop as one gateway does, so you no longer need session affinity for it. If the cluster does not answer within 50 ms, a gateway judges the hop on its own history, so a loop check never fails a call. Before 1.8.5 each pod kept its own history and caught a loop spread over pods later, or not at all.
From 1.8.5 calls to the same agent and skill that carry the same delegation chain count as one. Planner can ask Budget to price five options at once without the fifth being refused as a loop. Real recursion changes the chain on every round, so it is still refused at the same hop as before. Parallel calls from one chain still count toward the rate.
The kill is scoped to the workspace and session ID. Another workspace using the
same caller-supplied ID is unaffected. From 1.8.5 a kill stops the session on
every plane for 24 hours: its hops, its tool calls and its model calls. A
running message:stream or tasks:resubscribe stream ends with one
a2a_session_killed error event, and a hop held for approval is denied and
refused with A2A_SESSION_KILLED. See
Stop runaway agents.
Stream a long-running response
Use POST /a2a/{agentId}/message:stream with JSON-RPC method
message/stream. Streaming is on by default and uses Server-Sent Events.
From 1.8.1, a2a.streaming.enabled: false closes both streaming doorways.
message:stream and tasks:resubscribe answer a single SSE error event,
a2a_streaming_disabled, before the caller is authenticated, and startup logs a
warning so the posture is visible. Earlier releases accepted the setting and
ignored it, so the paths had to be blocked at the ingress instead.
The served Agent Card still advertises streaming, so a client that reads the card will try to stream and get the refusal.
Message size
One outbound message may carry up to a2a.limits.max-message-length characters
of text, counted across all of its parts. The default is 50,000, the same as the
LLM plane. A larger message is refused with 413 A2A_MESSAGE_TOO_LARGE before
policy and the PII scan run, on both message:send and message:stream, and the
refusal is audited and recorded as a hop. Set the property to 0 to turn the cap
off.
The cap is new in 1.8.1. Before it, the plane had no size limit, and a very large message could occupy a Gateway thread for minutes inside the PII scan.
Response size
From 1.8.5 a peer's answer has a size limit too. The gateway counts the bytes as it reads them and stops before it parses an answer that is too large:
| Answer | Setting | Default |
|---|---|---|
A whole answer to message:send or a task call, and a fetched Agent Card | a2a.response-limits.max-body-bytes | 16 MiB |
A message:stream or tasks:resubscribe stream, counted from its first byte | a2a.response-limits.max-stream-bytes | 64 MiB |
A workspace can set its own values with the same keys in its metadata. Past a
limit the call fails with 502 A2A_RESPONSE_TOO_LARGE, is not retried, and is
recorded with policy decision DENY. A stream is cut and ends with an error
event. An Agent Card over the limit gives A2A_CARD_UNAVAILABLE, or the cached
card is served. Before 1.8.5 a peer's answer of any size was read whole.
In 1.8, response-stream enforcement on the A2A plane is PII-only. LLM guardrail and grounding actions do not run on an A2A peer response. Configure the workspace PII action when the peer reply must be withheld, transformed, or refused before release.
Before opening the peer stream, DVARA authenticates the caller, resolves the
agent, evaluates policy, scans the outbound message, checks loops, and enforces
the session kill state. In 1.8, this streaming path does not run the human
approval or governed-call cap checks used by synchronous message:send.
Response delivery depends on the PII action:
LOGreleases events as they arrive because it does not withhold content.BLOCK,REDACT, andTOKENIZEhold the complete structured reply, enforce it once the peer stream ends, then release the safe result or an error.
Whole-response enforcement prevents a personal-data value split across several events from escaping detection, but it removes incremental delivery for a withholding action. The default hold limits are 1,000,000 text characters, 8,000,000 serialized bytes, and 20,000 events. Reaching a limit stops the stream instead of releasing partially checked content. The upstream connect timeout is 10 seconds and the read/idle timeout is 300 seconds. See the A2A streaming settings for every key and default.
From 1.8.5 a resumed stream is scanned as a continuation of the one it
resumes. Each stream keeps the last 256 characters of every text body for its
task (a2a.streaming.resume-carry-characters), in the pod's memory, for up to
10,000 tasks and one hour. A tasks:resubscribe stream is scanned together with
it, so a value split at the break, such as alexander.hamilton@treasury before
the drop and .example.com after it, is caught. REDACT and TOKENIZE redact
the part the resumed stream delivers, BLOCK refuses it, and LOG records the
value once. Nothing already delivered is changed. A resume is authorised again
from scratch, so a policy change made while the caller was away applies.
Every completed or interrupted stream writes A2A_STREAM_SUMMARY with the
enforcement outcome.
| Public outcome | Meaning |
|---|---|
A2A_STREAM_UNSCANNABLE | Deferred delivery exceeded its character, byte, or event bound, so no partially checked reply was released. |
A2A_PII_ENFORCEMENT_FAILED | A reply that required protection could not be parsed or safely scanned. |
A2A_UPSTREAM_TRUNCATED | The peer or its response reader failed before a clean end. From 1.8.5 a peer that breaks off a stream is recorded this way again; since 1.8.3 it had been recorded as a clean end. |
A2A_STREAM_INTERRUPTED | The client cancelled or abandoned delivery. This is not an upstream truncation. From 1.8.5 the gateway logs one INFO line naming the agent, the task and the trace id, and stops reading from the peer. It no longer logs an ERROR for each disconnect. |
The A2A_STREAM_SUMMARY fields are not aliases for one generic failure.
scan_incomplete marks an Immediate scan prefix that reached its bound,
scan_failed marks a detector failure after Immediate delivery,
truncated identifies an incomplete upstream reply, and error_code records
the terminal outcome. A refusal can also be truncated: true when DVARA found a
blocking issue in the partial reply received before the upstream failed.
The persisted response-size value for A2A message streams and task
resubscriptions records the event count instead of serialized bytes. Event
count in A2A_STREAM_SUMMARY remains valid. Track the correction in
the 1.8 known limitation.
Discover a peer's Agent Card
Fetch a registered peer's public card through DVARA:
curl --silent https://<dvara-host>/a2a/researcher/.well-known/agent-card.json \
--header 'Authorization: Bearer <your-api-key>'
DVARA filters the advertised skills through that registry entry's allow list.
An empty allow list exposes every skill in the peer's card. Cards are cached for
3,600 seconds by default; ?refresh=true forces a new fetch. If refresh fails
and a cached card exists, DVARA serves the cached copy.
Discovery labels the card VERIFIED, SELF_SIGNED, UNSIGNED, or INVALID.
Strict verification is off by default. Set
a2a.discovery.require-signed-cards: true to accept only a card verified against
the public key pinned for that agent. A card whose signature fails against a
pinned key is always refused, even when strict verification is off.
Govern against the extended Agent Card
A peer can give an authenticated caller a fuller card than its public one. From
1.8.4, when the public card declares such an extended card, DVARA fetches the
extended card from the agent's endpoint with the agent's credential: the stored
credential, or for a DELEGATED agent the caller's
X-DVARA-Delegated-Authorization token on the discovery request. DVARA then
checks that card's signature, filters its skills through the allow list, and
serves it marked x-dvara-card-kind: EXTENDED. Before 1.8.4 discovery read only
the public card, so the allow list was applied to the wrong card.
- The extended card is cached per workspace, agent and credential. It is never served to another workspace or to a caller with another credential.
- With no credential to present, DVARA serves the public card, and the
A2A_CARD_DISCOVEREDevent says why the extended card was skipped. - If the extended card cannot be fetched, discovery answers
502 A2A_CARD_UNAVAILABLErather than serving the public card.
A2A_CARD_DISCOVERED records card_kind, extended_card_declared,
extended_card_skipped and extended_only_skill_count. For an extended card it
also records public_card_trust_state beside the served card's
card_trust_state.
Continue and manage a peer task
The A2A Gateway exposes the v0.3 task operations beneath the registered agent:
| Endpoint | JSON-RPC method | Purpose |
|---|---|---|
POST /a2a/{agentId}/tasks:get | tasks/get | Read one task |
POST /a2a/{agentId}/tasks:list | tasks/list | List tasks |
POST /a2a/{agentId}/tasks:cancel | tasks/cancel | Ask the peer to cancel a task |
POST /a2a/{agentId}/tasks:resubscribe | tasks/resubscribe | Reattach to an SSE task stream |
Task operations authenticate the caller, resolve the workspace-scoped agent,
apply A2A policy, and write their own audit event. Task read responses are
PII-scanned. These operations do not represent a new delegation, so they do not
run message-loop detection or human approval. A tasks/resubscribe response
uses the same PII-only streaming enforcement and limits as message:stream.
| DVARA endpoint | Forwarded A2A method | Purpose |
|---|---|---|
POST /a2a/{agentId}/pushConfig:set | tasks/pushNotificationConfig/set | Create or replace a push destination; the URL passes the egress/SSRF check. |
POST /a2a/{agentId}/pushConfig:get | tasks/pushNotificationConfig/get | Read one push configuration. |
POST /a2a/{agentId}/pushConfig:list | tasks/pushNotificationConfig/list | List push configurations. |
POST /a2a/{agentId}/pushConfig:delete | tasks/pushNotificationConfig/delete | Delete one push configuration. |
Send the A2A method's parameter object as the JSON body and use the same Bearer and session headers as tasks:*. DVARA refuses an unsafe webhook URL before forwarding set. From 1.8.5 the webhook must be https:// too.
Receive push notifications through DVARA
A peer posts a task's result to the push-notification webhook the workspace set. Before 1.8.5 that result went from the peer straight to the workspace, with no PII scan, no audit record and no hop. From 1.8.5 the result comes through the gateway:
- On
pushConfig:set, and on a push config insidemessage:sendormessage:stream, the gateway gives the peer a callback of its own and a notification token, in place of the workspace's webhook URL, token and credentials.pushConfig:getandpushConfig:liststill show the workspace's own URL and token. - When the peer posts, the gateway checks the sender: the callback it sealed, the notification token it issued, and, for an agent with a pinned key, a signed token.
- It drops a repeat, applies the workspace's PII action, writes an
A2A_PUSH_NOTIFICATIONaudit event and records a hop. - It delivers the notification to the workspace's webhook, trying up to 3
times. If no try succeeds, the peer gets
502and can send again. The gateway stores nothing.
A notification is read only up to a2a.push.max-body-bytes (default 1 MiB).
Set the gateway's public URL, as peers reach it, in
A2A_PUSH_CALLBACK_BASE_URL. In the Helm chart, set
llmGatewayServer.a2a.pushCallbackBaseUrl. Until it is set, a push config is
refused with 503 A2A_PUSH_NOT_AVAILABLE and never forwarded.
A workspace can keep the old direct delivery with the setting
a2a.push.delivery=direct. The webhook URL is still checked and must be
https://, but the peer then posts to it without the gateway, so those
notifications are not scanned, audited or recorded.
Choose how DVARA authenticates to the peer
Registered agents use STORED auth by default. DVARA resolves the agent's
configured credential and presents it using API_KEY, HTTP, OAUTH2,
OPENID_CONNECT, or MUTUAL_TLS.
From 1.8.4 the Flightdeck agent forms offer From the Agent Card, the
default for a new agent. Flightdeck reads the peer's card and takes the one kind
of scheme it declares. If the card cannot be read, declares no scheme, or
declares more than one kind, the save is refused and you choose the scheme.
Nothing falls back to API_KEY.
A scheme you choose is compared with the card when you save the agent and each
time a gateway fetches the card. A mismatch is a warning, never a refusal: the
hop still uses the scheme you chose, so a working configuration keeps working.
A mismatch is a scheme the card does not accept, an API key the card wants in
another header, HTTP basic, or a DELEGATED bearer token to a peer that
accepts no bearer scheme. The warning appears:
- on the agent in the Flightdeck (a Card mismatch badge in the list, and the finding on the form);
- on the served card as
x-dvara-auth-scheme-finding; and - in the
A2A_CARD_DISCOVERED,A2A_AGENT_CREATEDandA2A_AGENT_UPDATEDaudit events.
Flightdeck also records, for each hop, the auth DVARA presented: the scheme,
DELEGATED_BEARER, or NONE.
In 1.8, stored OAuth2 and OpenID Connect credentials are presented as bearer
tokens; DVARA does not run a grant flow. MUTUAL_TLS uses the deployment's TLS
context rather than a separate client certificate for each agent.
Use DELEGATED when the peer should enforce the calling user's permissions:
X-DVARA-Delegated-Authorization: Bearer <upstream-user-token>
Authorization still carries the DVARA API key. A delegated target called
without the second token returns A2A_DELEGATED_TOKEN_REQUIRED. DVARA does not
store or log the delegated token; the A2A audit chain records a fingerprint in
A2A_DELEGATION_RECORDED.
Know the 1.8 protocol boundary
DVARA sends A2A v0.3 method names and message shapes to peers. A peer that
accepts only A2A v1.0 methods such as SendMessage cannot receive a hop from
DVARA 1.8. Signed Agent Card verification is supported, but the served card
still uses the v0.3 shape.
Where to go next
- Stop runaway agents and approve risky actions for the shared agent-control model.
- Manage A2A governance in Flightdeck to register agents, inspect hops, and resolve approvals.
- Govern tool calls with the MCP Gateway for an agent's calls to tools rather than peer agents.