In the Agent2Agent (A2A) protocol, an agent describes itself in an Agent Card: a JSON document listing its name, the URLs and protocol bindings it answers on, its capabilities, its authentication requirements and its skills. The simplest way for another agent to find that card is the well-known URI: given only a domain name, a client fetches https://{domain}/.well-known/agent-card.json. The A2A discovery guidance names this alongside curated registries and direct configuration, and recommends it for public agents and agents whose domain the client already knows.

Most writing on discovery takes the client's point of view; the resolver side is covered in A2A discovery. This page takes the publisher's. If you run an agent, the well-known card is a small endpoint with large consequences: it is the first thing every new client sees, it is cached by clients you do not control, and if it disagrees with your real endpoints, nothing works. Below are what the well-known convention means, how to serve the card, how to keep it in step with deployments, how changes propagate, what belongs in the public card, and how to test it, with a worked example. Protocol details were checked against a2a-protocol.org on 2026-10-02.

Advertisement

What a well-known URI is

RFC 8615 reserves the /.well-known/ path prefix on any HTTP origin for site-wide metadata, so that clients can find a resource without being told its location. Familiar examples are OpenID Connect discovery documents and security.txt. A2A uses the same convention: the card for an origin lives at /.well-known/agent-card.json. Early versions of the protocol used a different file name, agent.json; current A2A uses agent-card.json, and clients written against old samples may still request the old one.

The important word is origin: scheme, host and port. The path belongs to the whole origin, so one origin publishes at most one well-known card. A client given acme.example gets the card at that host, not the card at invoices.acme.example, and there is no standard way to put a second agent's card at acme.example/.well-known/ under another name. That single fact drives most of the design decisions below.

Well-known discovery: from a domain name to the first taskclient agentknows: invoices.acme.exampleGET well-known card/.well-known/agent-card.jsonedge or CDNTLS, cache, ETagbuild URLcard originrendered from deploy configmiss200 or 304Cache-Control, ETagvalidate cardschema, HTTPS, signatureneed private skills?GetExtendedAgentCardif flaggedfirst requestsupportedInterfaces[0]authenticatedagent endpointJSON-RPC, REST or gRPCThe card is metadata about the endpoint, not the endpointit can live on a different host from the interfaces it lists, and it must stay in step with them
Discovery from the publisher's side. The client derives the card URL from a domain, the edge serves it with caching headers, the client validates it and calls the first supported interface. The card can live on a different host from the endpoint it describes.

One origin, one card: hosting several agents

Organisations rarely run one agent. There are three workable layouts.

LayoutHowWhen it fits
Subdomain per agentinvoices.acme.example and travel.acme.example each serve their own well-known cardIndependent agents owned by different teams; the default choice
One agent, many skillsA single card lists several skills behind one endpoint, which routes internallyOne team, shared auth and lifecycle, a gateway in front
Registry or direct configCards live at ordinary URLs; clients find them through a catalogue or configurationMany internal agents, or agents that should not be publicly discoverable

Avoid publishing agents only at path-prefixed card URLs such as acme.example/agents/invoices/card.json and expecting well-known discovery to find them; it will not. That layout is fine with a registry, which stores the URL explicitly. Registry design is covered in agent registry architecture.

Note that the card and the endpoint need not share a host. The card at invoices.acme.example can list an interface at api.acme.example/a2a/invoices. The card's origin is what the client trusts for the description, so only list endpoints you control.

Advertisement

Serving the card correctly

The card is a static document most of the time, and should be served like one: from the edge, over HTTPS, without authentication, with JSON content type and caching headers. A2A's discovery guidance recommends a Cache-Control header with an appropriate max-age, and an ETag derived from the card's version or a hash of its content, so clients can revalidate with If-None-Match and receive a cheap 304 when nothing has changed.

from pathlib import Path
from starlette.applications import Starlette
from starlette.responses import Response
from starlette.routing import Route

CARD = Path("dist/.well-known/agent-card.json").read_bytes()
ETAG = Path("dist/.well-known/agent-card.etag").read_text().strip()

async def agent_card(request):
    headers = {
        "Cache-Control": "public, max-age=300",
        "ETag": ETAG,
        "Access-Control-Allow-Origin": "*",      # public card only; see the CORS note
    }
    if ETAG in request.headers.get("if-none-match", ""):
        return Response(status_code=304, headers=headers)
    return Response(CARD, media_type="application/json", headers=headers)

app = Starlette(routes=[Route("/.well-known/agent-card.json", agent_card)])

A few details matter. Serve over HTTPS only; a card fetched over plain HTTP can be altered in transit. Do not redirect the well-known path to another origin: careful clients refuse cross-origin redirects, because the redirect would let a different host speak for your domain. Keep the card small, a few kilobytes, by keeping skill descriptions crisp. Return 404 rather than an HTML error page if an origin has no agent, so clients get a clean negative.

CORS is not covered by the A2A discovery guidance. If browser-based clients need to read your public card, a permissive Access-Control-Allow-Origin on the well-known path is reasonable, since the card is public anyway. Do not copy that header to your task endpoints or the extended card, which carry credentials and deserve an explicit allow list.

Render the card from deployment config

The most common real failure is drift: the card says one thing and the deployment does another. Someone moves the endpoint, adds OAuth or retires a skill, and the hand-edited card still advertises the old state. Clients then fail in confusing ways, and because the card is cached, they keep failing for a while after the fix.

Treat the card as a build artefact. Keep a template under version control next to the agent's code, fill in environment-specific values such as public URLs and the release version in the deploy pipeline, validate the result, and compute the ETag from the final bytes. Here is the card the pipeline produces for the worked example, using the field structure of the A2A 1.0 specification:

{
  "name": "Acme Invoice Agent",
  "description": "Looks up, explains and disputes Acme invoices for business customers.",
  "version": "2.4.0",
  "provider": {"organization": "Acme Corp", "url": "https://acme.example"},
  "supportedInterfaces": [
    {"url": "https://invoices.acme.example/a2a/v1",
     "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}
  ],
  "capabilities": {"streaming": true, "pushNotifications": false,
                   "extendedAgentCard": true},
  "securitySchemes": {
    "acme_oauth": {"oauth2SecurityScheme": {"flows": {"clientCredentials": {
      "tokenUrl": "https://auth.acme.example/oauth2/token",
      "scopes": {"invoices.read": "Read invoices"}}}}}
  },
  "securityRequirements": [{"schemes": {"acme_oauth": {"list": ["invoices.read"]}}}],
  "defaultInputModes": ["text/plain", "application/json"],
  "defaultOutputModes": ["text/plain", "application/json"],
  "skills": [
    {"id": "invoice-lookup", "name": "Invoice lookup",
     "description": "Find an invoice by number, customer or date range and explain each line.",
     "tags": ["invoices", "billing"]}
  ]
}
# render_card.py - run in the deploy pipeline, after the endpoint URLs are known
import hashlib, json, os, pathlib

def render(env):
    card = json.loads(pathlib.Path("card.template.json").read_text())
    card["version"] = env["RELEASE_VERSION"]
    card["supportedInterfaces"][0]["url"] = env["PUBLIC_A2A_URL"]
    for iface in card["supportedInterfaces"]:
        assert iface["url"].startswith("https://"), iface["url"]
        assert "staging" not in iface["url"] or env["STAGE"] == "staging", iface["url"]
    body = json.dumps(card, sort_keys=True, separators=(",", ":")).encode()
    etag = '"' + hashlib.sha256(body).hexdigest()[:32] + '"'
    out = pathlib.Path("dist/.well-known")
    out.mkdir(parents=True, exist_ok=True)
    (out / "agent-card.json").write_bytes(body)
    (out / "agent-card.etag").write_text(etag)
    return etag

if __name__ == "__main__":
    print(render(os.environ))

The assertions are cheap insurance: a staging URL in a production card and a plain HTTP endpoint are both mistakes that have been seen in published cards. If you sign the card, sign in this same step, after every value is final, because any later edit invalidates the signature. The specification defines card signatures as JWS over a canonicalized form of the card; field-level rules are in the Agent Card specification, field by field.

How card changes reach clients

Once published, a card is cached at your CDN, in shared caches and in every client, for up to max-age after each fetch, and some clients cache longer than you ask. So assume that for some period after a change, clients act on the old card. Choose max-age with that in mind: five to fifteen minutes is a reasonable default for a production agent, long enough to absorb traffic, short enough that a fix spreads quickly. ETags make short lifetimes cheap, because revalidation costs a 304.

Order changes so that both versions of the card are valid at once. To move an endpoint, deploy the new endpoint first, publish a card that lists it first and the old one second, wait at least one cache lifetime plus margin, then retire the old endpoint and its card entry. To tighten authentication, accept both old and new credentials on the endpoint before publishing the stricter card. To remove a skill, stop advertising it, wait, then remove the code. The same logic applies to bumping a protocol version, which is covered in capability discovery from the client side.

Purge the CDN after publishing, but do not rely on purges: they clear only your caches, not your clients'.

What belongs in the public card

The well-known card is world-readable. Anything in it is public: skill names that reveal unannounced products, internal hostnames, example inputs containing customer data, contact addresses that attract spam. Keep the public card to what an unknown client needs to decide whether to authenticate: identity, interfaces, security schemes, and the skills you are willing to advertise.

For the rest, A2A defines an authenticated extended card. Set capabilities.extendedAgentCard to true in the public card, and an authenticated client can fetch a richer card through the Get Extended Agent Card operation, which can vary by caller. Serve it with Cache-Control: private or no-store, never from a shared cache. Endpoint security is covered in A2A security.

Test the card like an API

Because every client depends on the card, test it on every deploy and on a schedule, from outside your network. A conformance check fetches the card exactly as a stranger would, validates it, confirms caching works, and confirms that each advertised interface is alive.

# check_card.py - run after every deploy and on a schedule
import json, sys, httpx

def check(domain):
    url = f"https://{domain}/.well-known/agent-card.json"
    problems = []
    with httpx.Client(follow_redirects=False, timeout=10) as h:
        r = h.get(url)
        if r.status_code != 200:
            return [f"{url} returned {r.status_code}"]
        if "json" not in r.headers.get("content-type", ""):
            problems.append("content-type is not JSON")
        if len(r.content) > 256_000:
            problems.append("card larger than 256 KB")
        card = json.loads(r.content)
        for key in ("name", "version", "supportedInterfaces", "skills"):
            if key not in card:
                problems.append(f"missing {key}")
        etag = r.headers.get("etag")
        if not etag:
            problems.append("no ETag")
        elif h.get(url, headers={"If-None-Match": etag}).status_code != 304:
            problems.append("conditional GET does not return 304")
        for iface in card.get("supportedInterfaces", []):
            if iface.get("protocolBinding") == "GRPC":
                continue                     # host:port, probe with a gRPC client instead
            if not iface["url"].startswith("https://"):
                problems.append(f"non-HTTPS interface {iface['url']}")
            elif h.post(iface["url"], json={}).status_code >= 500:
                problems.append(f"interface {iface['url']} is failing")
    return problems

if __name__ == "__main__":
    found = check(sys.argv[1])
    print("\n".join(found) or "ok")
    sys.exit(1 if found else 0)

The interface probe only checks that the endpoint answers without a server error; an unauthenticated empty request should be rejected, which is fine. Extend the check with JSON Schema validation against the published A2A schema and, if you sign, with signature verification. Alert on a failing check exactly as you would on a failing health check, and log well-known requests by user agent to see which clients still request the old agent.json path.

Worked example: publishing the Acme invoice agent

Acme runs its invoice agent behind a gateway at invoices.acme.example. The template lives in the agent's repository. On each release the pipeline renders the card with the public URL and release version, asserts HTTPS and no staging hosts, writes the bytes and an ETag, uploads both to the CDN, purges the path and runs the conformance check against production. The card is served with a 300-second max-age.

In month three, Acme moves the endpoint from /a2a/v1 to a new cluster at /a2a/v2. Release one deploys the v2 endpoint and a card listing v2 first and v1 second. The team waits a day, watching v1 traffic fall to a few clients that pinned the old card in configuration, contacts those owners, and then release two removes v1 from the card and the gateway. No client sees a failed call, because at every moment the card it held listed a working endpoint.

Failure modes

SymptomCauseFix
Clients call a dead URLCard edited by hand; drifted from deployRender the card in the pipeline from the same config as the endpoint
Fix deployed, clients still failLong max-age or client-side cachingShort max-age with ETags; overlap old and new endpoints
Second agent undiscoverableTwo agents on one originSubdomain per agent, or a registry
Card fetch fails for some clientsRedirect to another origin, or HTML error pageServe the path directly; return a plain 404 when absent
Internal names leakedEverything put in the public cardMinimal public card; details in the extended card
Signature fails verificationCard modified after signingSign as the last render step; never edit served bytes
Old clients get 404They request agent.jsonDecide deliberately: redirect within the origin, or log and contact them

What to do next

  1. Decide your layout: one subdomain per independently owned agent, or a gateway with one card listing several skills.
  2. Move the card into the agent's repository as a template and render it in the deploy pipeline with HTTPS and environment assertions.
  3. Serve it from the edge with JSON content type, a short max-age and an ETag from the rendered bytes, and confirm a conditional GET returns 304.
  4. Audit the public card for anything that should not be world-readable, and move it to the extended card.
  5. Write down the rollout order for endpoint moves and auth changes, with an overlap of at least one cache lifetime.
  6. Run the conformance check after every deploy and on a schedule, and alert on failures.
Key takeaway: The well-known Agent Card is one public document per origin, served at /.well-known/agent-card.json under RFC 8615, and every new client starts there. Give each independent agent its own subdomain or use a registry, render the card from deployment config so it cannot drift, serve it over HTTPS from the edge with a short max-age and an ETag, keep sensitive details in the authenticated extended card, roll out changes so old and new cards are both valid during the cache lifetime, and check the published card after every deploy as you would any API.