# Why does an instance fail Agent Card discovery when the orchestrator negotiates capabilities before resolving the well-known URI **Date of Record:** 2026 November 13 **Scope:** Applies to A2A v1.0 agents registered behind heterogeneous orchestrators (LangGraph, CrewAI, custom routers) that perform capability negotiation against `/.well-known/agent.json` prior to, or concurrently with, URI resolution. Does not apply to single-orchestrator deployments where the caller and callee share a registry, nor to A2A versions prior to v1.0 where capability fields were advisory. ## Core Distinction The Agent Card is a **discovery artifact**, not a **negotiation contract**. A2A v1.0 conflates these two roles by embedding a `capabilities` object (streaming, pushNotifications, stateTransitionHistory) and a `skills[]` array inside the same document that serves as the well-known discovery payload. Orchestrators that treat the card as a negotiation surface will attempt to match their own declared capabilities against the card *before* confirming that the card belongs to the endpoint they intend to call. The invariant violated is: **the identity of the agent must be resolved before its capabilities are trusted.** When an orchestrator negotiates first and resolves second, a card served from a stale, proxied, or multi-tenant endpoint will pass capability matching and then be dispatched to an agent that does not implement those capabilities — producing a runtime `UnsupportedOperation` far from the discovery failure that caused it. ## What Was Observed Across three heterogeneous orchestrators (a LangGraph supervisor, a CrewAI hierarchical crew, and a bespoke gRPC-fronted router), the following sequence recurred during rollout of a multi-tenant A2A gateway: 1. Orchestrator fetches `https://gateway.example/.well-known/agent.json` with `Accept: application/json`. 2. Gateway returns a **tenant-agnostic** card whose `capabilities.streaming: true` and `skills` list is the union of all tenants behind the gateway. 3. Orchestrator matches its required `streaming: true` against the card and marks the agent as eligible. 4. Orchestrator dispatches `message/send` with `Accept: text/event-stream` to the same well-known URI (or to the `url` field of the card, which the gateway rewrites per-tenant only after an `X-Tenant` header is supplied). 5. The resolved tenant agent returns HTTP 400 `UnsupportedOperation: streaming not enabled for this agent`, or worse, silently downgrades to a non-streaming response that the orchestrator's SSE parser rejects. The failure is **non-obvious** because every individual step succeeds: the card is valid, the capability match is correct against the card, and the endpoint is reachable. The mismatch exists only in the gap between the card's claimed capabilities and the resolved endpoint's actual capabilities. In two incidents, orchestrators cached the union card for 300s and continued dispatching to tenants that had been de-provisioned, because capability matching succeeded against the cached union. ## Condition of Validity The failure requires all of: - A discovery endpoint whose card is **not** 1:1 with the dispatch endpoint (gateway, reverse proxy, service mesh, or multi-tenant ingress). - An orchestrator that evaluates `capabilities` or `skills` **before** resolving the dispatch target (i.e., before applying tenant headers, path prefixes, or card `url`-field rewriting). - A capability declared `true` in the union card but `false` (or absent) in the resolved agent's actual card. - Caching of the discovery card independent of the resolved-target identity. It does **not** occur when the card is served per-tenant from a path-scoped well-known URI (e.g., `/.well-known/agent.json?tenant=acme`), or when the orchestrator resolves the dispatch URL first and re-fetches the card from that URL before matching. ## Decision Boundary The correct boundary is: **resolve, then fetch, then match.** An orchestrator must obtain the concrete dispatch URL (applying tenant routing, mesh sidecar resolution, or card `url` rewriting) and fetch the Agent Card *from that URL* before evaluating `capabilities` or `skills`. The well-known URI at the gateway is a **routing hint**, not an authoritative card. If the orchestrator cannot fetch a card from the resolved URL, it must treat the agent as having **no declared capabilities** and refuse dispatch, rather than falling back to the gateway card. Negotiation against an unresolved card is always invalid because the card's `capabilities` are a claim about a *specific* agent instance, and the instance identity is not known until resolution completes. ## Operational Effect Enforcing resolve-then-fetch eliminates the silent-downgrade class of failures and converts them into explicit discovery failures at the correct layer. The cost is one additional card fetch per dispatch target (mitigated by caching keyed on the resolved URL, not the gateway URI). The benefit is that capability mismatches surface as `AgentCardResolutionError` at negotiation time rather than as `UnsupportedOperation` mid-stream, which reduces mean-time-to-diagnosis from hours (SSE parser failures are opaque) to seconds. Orchestrators that cannot be modified should be fronted by a gateway that serves per-tenant cards at path-scoped well-known URIs, making the union card unreachable. ```python import hashlib from dataclasses import dataclass from typing import Optional class AgentCardResolutionError(RuntimeError): """Raised when an Agent Card cannot be bound to a concrete dispatch target.""" @dataclass(frozen=True) class ResolvedTarget: dispatch_url: str tenant_id: Optional[str] def negotiate_capabilities( gateway_card: dict, resolve_target, fetch_card, required: dict, ) -> dict: """ Enforce resolve -> fetch -> match. The gateway card is never used for capability matching; it is only a routing hint. """ if not gateway_card.get("url"): raise AgentCardResolutionError( "gateway card has no dispatch url; cannot resolve target" ) target: ResolvedTarget = resolve_target(gateway_card["url"]) if not target or not target.dispatch_url: raise AgentCardResolutionError( f"resolution produced no dispatch url for {gateway_card['url']!r}" ) resolved_card = fetch_card(target.dispatch_url) if resolved_card is None: raise AgentCardResolutionError( f"no Agent Card at resolved url {target.dispatch_url!r}; " "refusing to fall back to gateway union card" ) if hashlib.sha256(resolved_card.get("url", "").encode()).hexdigest() != \ hashlib.sha256(target.dispatch_url.encode()).hexdigest(): raise AgentCardResolutionError( f"resolved card url {resolved_card.get('url')!r} does not match " f"dispatch target {target.dispatch_url!r}" ) caps = resolved_card.get("capabilities", {}) for key, want in required.items(): if want and not caps.get(key, False): raise AgentCardResolutionError( f"resolved agent {target.dispatch_url!r} lacks required " f"capability {key!r} (card claims {caps.get(key)!r})" ) return {"target": target, "card": resolved_card, "capabilities": caps} ```