A2A Agent Card Discovery
Purpose
This functional area lets an A2A client discover what one of your agents can do — its identity, security requirements, and callable skills — before sending any message.
Main Capabilities
- publish a public, unauthenticated Agent Card for an agent, carrying its identity, endpoint, security schemes and every skill it offers
- publish an authenticated extended card narrowing those skills to the ones the calling identity may invoke
- declare the platform's actual inbound security schemes — JWT bearer and API key, as alternatives — rather than inventing new ones
How Discovery Works
A card describes an agent. Each registered agent — hosted or remote — has its own card at its own
address, …/a2a/agents/{agentId}/.well-known/agent-card.json. There is no protocol-level way for a caller
to discover an agent it does not already know about; an agent's address is handed out as configuration, the
same way clientId already is, and never enumerated. That is deliberate: listing your agents to anyone who
can reach the platform would disclose your internal topology.
A hosted agent's card lists the skills an administrator authored on it. A remote agent's card carries what that agent publishes about itself, fetched and cached by this platform, with the endpoint and security details rewritten to this platform's own — you authenticate to us, not to it.
Tools are not skills, and there is no tenant-wide card. Earlier versions published a card per
(tenantId, clientId)whoseskills[]was your exposed MCP tool catalog reshaped into A2A's skill format, so a caller could invoke a tool by naming it over A2A. That is gone. A tool is reachable over MCP, which is the protocol for calling tools; A2A is the protocol for talking to agents. If you want an agent to use your tools, give a hosted agent skills that use them — then callers get an agent that reasons about your tools, rather than a thin disguise over a tool call.
Public Card vs Authenticated Extended Card
A2A makes discovery unauthenticated, and this platform decides what a caller may use from that caller's identity. Those two facts settle the split between the two cards:
- Public card (
GET …/a2a/agents/{agentId}/.well-known/agent-card.json, unauthenticated): the agent's identity, its endpoint, how to authenticate to it, the fact that an extended card exists (capabilities.extendedAgentCard: true), and every skill that agent has — what it can potentially do, for somebody. It is not filtered, because there is no caller to filter by. - Authenticated extended card (
GetExtendedAgentCardposted to the agent's own endpoint, orGET …/a2a/agents/{agentId}/agent-card/extended): the same card narrowed to the skills this caller is authorized to invoke. This is the specification's own mechanism for it: an extended card "MAY return different details based on client authentication level, including additional skills … not available in the public Agent Card".
So the sequence a client follows is: fetch the public card to see what the agent offers and how to
authenticate, authenticate, then ask for the extended card and work from the skills it returns. An extended
card is served with Cache-Control: no-store, because its content is specific to the caller who asked.
The public card is a description, not a grant. A skill appearing there says the agent has it, not that any particular caller may run it. Every invocation is authorized at call time against the same policies as before, so a caller that reads a skill off the public card and is not entitled to it is refused when it calls. The public card is exactly as revealing as that agent's own configuration — which is the deliberate choice: an agent nobody can see anything about is not discoverable, and A2A discovery is unauthenticated by design.
An agent only has a card at all once an administrator publishes it. Until then the address answers 404 —
the same answer it gives for an agent that does not exist, so an unpublished agent's existence is not
disclosed by a distinguishable response.
Setting an agent's address
An agent is reached at https://{agent}-{tenant}.{agents root}. The two labels are set separately — the
tenant's on the tenant editor, the agent's on the agent editor's Subdomain field — and the deployment
supplies the root. Lowercase letters and digits only, up to 31 characters each, and no dashes in either
half: the dash is what separates them, so one inside a label would make the address ambiguous. The two
together plus the separator are exactly one DNS label, which is what lets a single wildcard certificate
cover every agent of every tenant.
Leave the field blank when registering an agent and one is derived from its name — lowercased, with
everything outside a-z0-9 dropped, and a numeric suffix if another agent in the tenant already holds it. A
name that yields nothing at all (punctuation only, or another script) falls back to agent plus a fragment
of the id, because an agent that cannot be created because of its display name is a worse answer than an
ugly address. Derivation happens once, on create: renaming an agent afterwards leaves its address alone,
since an address that moved as a side effect of editing a display name is a change nobody decided to make.
Editing the subdomain is fully supported, and the editor shows the resulting address as you type. Saving a changed one asks you to confirm, showing the address before and after — because what the change costs is invisible from the field itself: everything holding the old address stops being able to reach the agent the moment it saves, and there is no redirect. Changing a tenant's subdomain moves every agent in that tenant at once, for the same reason and with the same warning. Nothing is stored composed, so there is nothing to cascade or recreate; the old addresses simply stop resolving and the new ones start.
An agent with no subdomain, or in a tenant with none, has no address at all — the editor says so rather than showing a URL that resolves to nothing, and the agent cannot be published until both exist.
Declared Transports
The card's supportedInterfaces is how a client picks a transport, so it lists every binding the agent is
actually reachable on: JSONRPC, HTTP+JSON and GRPC, all three at the agent's own address. An omitted
binding is a binding nobody uses, whatever the platform serves behind it.
All three share one URL because the agent's address is a host and each binding owns a disjoint part of it:
gRPC's methods all sit under /lf.a2a.v1.A2AService/, HTTP+JSON's are named methods, and JSON-RPC has the
root to itself.
GRPC was deliberately absent until the specification's proto was adopted, and the reason is worth knowing
if you are reading an older card. The platform served its own service name, codedprojects.apimapper.runtime.a2a.v1.A2aGrpcService,
while A2A 1.0 specifies lf.a2a.v1.A2AService — and a gRPC method's URL is derived mechanically from the
proto, so the package name is the address. A conforming client that trusted a GRPC entry would have
called a service that did not exist. The platform now generates its service from the specification's own
a2a.proto, byte for byte, so the entry is true and is declared.
Security Scheme Declaration
The card's declared securitySchemes reflect what the platform's inbound authentication middleware
already enforces — JWT bearer via a trusted identity provider, or an API key — never something the
platform doesn't actually support. No new inbound authentication mechanism is introduced by A2A; the card
simply describes the two schemes that already gate every other Runtime endpoint.
Every card declares both ways of authenticating, because the Runtime accepts both: a bearer JWT in
Authorization, or a key in X-Api-Key. A card that declared only the first would tell a client holding a
valid API key that it cannot call the agent at all.
They are written in the A2A 1.0 shape, which is the specification's protobuf JSON mapping and not OpenAPI's, even though the two look superficially alike:
"securitySchemes": {
"bearerAuth": { "httpAuthSecurityScheme": { "scheme": "bearer", "bearerFormat": "JWT" } },
"apiKeyAuth": { "apiKeySecurityScheme": { "location": "header", "name": "X-Api-Key" } }
},
"securityRequirements": [
{ "schemes": { "bearerAuth": { "list": [] } } },
{ "schemes": { "apiKeyAuth": { "list": [] } } }
]
A scheme names its kind by the field it is carried under, not by a type property alongside its members,
and the requirement that points at it is an object with a schemes map whose values are string lists —
not OpenAPI's "security": [{"bearerAuth": []}].
The two requirements are alternatives, and the structure is what says so. Schemes named together inside one requirement must all be satisfied; separate entries in the list are separate ways in. Satisfy either one and the call is authenticated — a client is never asked for a JWT and an API key.
A field the card does not set (provider, documentationUrl, a skill's description) is left out entirely
rather than sent as null.
Skill Identity
A skill's id is a stable, URL-safe identifier derived from its name — "Company Documents Search" is
published as company-documents-search — and it is what appears in audit records and logs. The display
name is free to contain spaces and punctuation; the id is not, because it travels in requests and routing
keys.
Card Signing And The Tenant's JWKS
A tenant's outbound Agent Cards are signed with a dedicated RSA-2048 keypair, and the public half is served
at that tenant's GET /runtime/{tenantId}/.well-known/jwks.json. A verifier picks the right key
out of that document by the kid in the card's JWS protected header.
Every signature also carries jku, the address of that key set, so a client does not have to guess where it
is. That matters here: a tenant's keys live under /runtime/{tenantId}/.well-known/jwks.json, and
the usual convention — try the host's own /.well-known/jwks.json — names no tenant and finds nothing.
The trust anchor is the origin, not the agent. When this platform verifies somebody else's card, it takes
the path from that card's jku but always the origin from the URL the card itself was fetched from; a
jku naming another host is ignored, because following one would be an SSRF vector. So the claim a signature
makes is "this platform, at this host, published this card". One key per tenant expresses exactly that. A key
per agent would publish several keys at one origin, any of which verifies any card there — kid selects a
key, it does not authorize one — so it would cost more key management for isolation no verifier can act on.
It follows that a card and the key set that verifies it must always share an origin: if an agent is ever
served on a host of its own, its tenant's JWKS has to be published at that host too.
What is signed
The signature covers the card canonicalized per RFC 8785 (JCS), with the signatures field itself
removed and default-valued properties (empty lists, empty objects, false, empty strings, nulls) pruned —
the preparation A2A 1.0 §8.4.1 prescribes, and the same reduction §8.4.3 tells a verifier to perform on the
card it received. A client following the specification therefore arrives at the byte sequence this platform
signed, whether or not the served card carried those empty fields. The protected header carries alg,
typ and kid as the specification requires, plus jku.
Keys are provisioned, never minted on demand
A tenant's key is created against the tenant, in the background, and the Runtime never writes one. A brand new tenant therefore has no JWKS for the few seconds before provisioning reaches it: the endpoint answers 404, and its card is served unsigned, which is valid — an unsigned card is explicitly not an error to a verifier.
Rotation and revocation are different operations
| Rotate | Revoke | |
|---|---|---|
| For | routine hygiene, key-lifetime policy | compromise |
| The old key | stays published for an overlap window, then is pruned | leaves the JWKS immediately |
| Cards already signed with it | keep verifying | stop verifying — the intended effect |
The overlap is what makes rotation safe. Cards, once fetched, live in directories and caches whose lifetimes this platform does not control, so removing a key the moment it stops signing would make every card in circulation unverifiable for a period somebody else decides. Revocation does exactly that, on purpose, and is a separate operation for that reason.
A retired or revoked key keeps only its public half — the private material is deleted from the secret store as it leaves service, so it cannot sign again even if something else goes wrong.
Functional Value
This area lets an A2A-compliant client — an orchestrator, another agent — discover a tenant's governed capabilities without a human first configuring a bespoke integration, the same value MCP's discovery surface already provides, extended to a second class of caller.
