Table of contents
An A2A Agent Card belongs at /.well-known/agent-card.json, served as application/json, with eight required top-level fields filled in. The older path, /.well-known/agent.json, came from A2A 0.2.x and was renamed in 0.3.0, so a client written for the current spec will not look there. This guide shows how to publish the card on a static host, Next.js, Astro, Cloudflare, Netlify, Vercel and WordPress, and how to prove it parses with the official Python SDK before any agent tries it.
By M Asif Rahman. Updated 12 November 2026. Every result below comes from builds and scripts we ran on 12 November 2026 with a2a-sdk 1.3.0, Next.js 16.4.6 and Astro 7.4.11.
Quick summary
| Question | Short answer | Source |
|---|---|---|
| Which path? | /.well-known/agent-card.json | A2A spec 1.0.0, §8.2 and §14.3 |
Is agent.json still valid? | Only in the 0.2.x spec. 0.3.0 and 1.0.0 use agent-card.json | a2a-protocol.org, versions 0.2.6, 0.3.0, 1.0.0 |
| Content type | application/json | spec §14.3 example, our Next.js and Astro builds |
| Required card fields | name, description, version, supportedInterfaces, capabilities, defaultInputModes, defaultOutputModes, skills | REQUIRED field behaviour in a2a-sdk 1.3.0 |
| Quickest validator | the 40-line script below, built on the SDK's own parser | our lab, 8 test cards |
| Does AIScan score it? | No. AIScan checks the MCP Server Card (P2) and its neighbours, not A2A cards | AIScan check list, 12 Nov 2026 |
| Who does check it? | Cloudflare's Is It Agent Ready, check a2aAgentCard | its scan evidence for aiscan.site, 12 Nov 2026 |
If you only have five minutes: copy the minimal card below, put it in your site's public/.well-known/ folder, deploy, and run the validator against the live URL.
Why two paths exist
A2A (Agent2Agent) is the open protocol for one AI agent to find and call another. The Agent Card is its business card: who the agent is, which URL and transport to use, what auth it wants and what skills it offers. A client that only knows your domain finds the card by fetching a fixed URL, the same well-known URI pattern RFC 8615 defines for security.txt and friends.
The fixed URL changed once. Each version below was fetched from a2a-protocol.org on 12 November 2026, and we counted the discovery path it names:
| Spec version | Discovery path it names | Card shape |
|---|---|---|
| 0.2.6 | /.well-known/agent.json | top-level url, protocolVersion |
| 0.3.0 | /.well-known/agent-card.json | top-level url, preferredTransport |
| 1.0.0 (latest) | /.well-known/agent-card.json | supportedInterfaces list |
According to the current specification, §14.3 registers the suffix agent-card.json with status Permanent, and the IANA Well-Known URIs registry lists it. The registry has no entry for agent.json. The Python SDK hard-codes the new path too: AGENT_CARD_WELL_KNOWN_PATH = '/.well-known/agent-card.json' in a2a/utils/constants.py.
That is why tutorials disagree. Anything written in mid-2025 against 0.2.x says agent.json, and plenty of those posts still rank. Our llms.txt vs robots.txt vs sitemap guide already notes the registered name. This post is the setup half.
What a valid card needs
According to the field options in a2a-sdk 1.3.0's protobuf model, these fields are REQUIRED. We read them from the installed package, not from a blog post:
| Object | Required fields |
|---|---|
| AgentCard | name, description, version, supportedInterfaces, capabilities, defaultInputModes, defaultOutputModes, skills |
| AgentInterface (each entry) | url, protocolBinding, protocolVersion |
| AgentSkill (each entry) | id, name, description, tags |
| AgentProvider (if present) | organization, url |
A minimal card that passes:
{
"name": "Example Support Agent",
"description": "Answers order-status questions for example.com customers.",
"version": "1.0.0",
"supportedInterfaces": [
{"url": "https://example.com/a2a/v1", "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}
],
"capabilities": {"streaming": false},
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": [
{"id": "order-status", "name": "Order status", "description": "Looks up an order by number.", "tags": ["orders"]}
]
}
version is your agent's version, a string. protocolVersion lives on each interface and names the A2A version that endpoint speaks. According to the spec's §14.3 security notes, the card should not include sensitive credentials or internal implementation details, and HTTPS should carry it. Keep API keys out of it; the securitySchemes field describes how to authenticate, it never holds the secret.
Check it: the scan first, then the validator
Run the AIScan scan first (npx aiscan-cli yoursite.com, or paste the URL at aiscan.site, free, no account). It will not grade the A2A card, and we would rather say so than imply it does. What it does grade is the discovery layer the card sits in: P1 (API catalog), P2 (MCP Server Card), P3 (Agent Skills index) and P4 (OAuth discovery), all listed on the capabilities checks page. If P2 already passes, your host serves /.well-known/ correctly and the card will be reachable at the same prefix.
For the card itself, Cloudflare's Is It Agent Ready runs a check called a2aAgentCard. Its evidence for aiscan.site on 12 November 2026 showed exactly one request, GET /.well-known/agent-card.json, then a conclusion. It did not try agent.json.
If you would rather check by hand, this script wraps the SDK's own parse_agent_card() and then walks every REQUIRED field:
#!/usr/bin/env python3
# pip install a2a-sdk==1.3.0 then: python validate_card.py https://example.com/.well-known/agent-card.json
import json, sys, urllib.request
from a2a.client.card_resolver import parse_agent_card
from google.api import field_behavior_pb2 as fb
def load(src):
if src.startswith('http'):
req = urllib.request.Request(src, headers={'User-Agent': 'card-check/1.0'})
with urllib.request.urlopen(req, timeout=15) as r:
print('HTTP', r.status, r.headers.get('Content-Type'))
return json.load(r)
return json.load(open(src))
def missing(msg, path):
out = []
for f in msg.DESCRIPTOR.fields:
name = f'{path}.{f.json_name}'
required = fb.REQUIRED in f.GetOptions().Extensions[fb.field_behavior]
val = getattr(msg, f.name)
if f.is_repeated:
if required and len(val) == 0: out.append(name)
if f.message_type and not f.message_type.GetOptions().map_entry:
for i, item in enumerate(val): out += missing(item, f'{name}[{i}]')
elif f.message_type:
if msg.HasField(f.name): out += missing(val, name)
elif required: out.append(name)
elif required and val in ('', 0):
out.append(name)
return out
raw = load(sys.argv[1])
legacy = [k for k in ('url', 'protocolVersion', 'preferredTransport', 'additionalInterfaces') if k in raw]
try:
card = parse_agent_card(dict(raw))
except Exception as e:
print('FAIL parse:', e); sys.exit(1)
gaps = missing(card, 'card')
if legacy: print('NOTE legacy 0.x fields mapped by the SDK:', ', '.join(legacy))
for i in card.supported_interfaces:
print(f'interface {i.protocol_binding} {i.protocol_version} {i.url}')
print('missing required:', ', '.join(gaps) if gaps else 'none')
print('RESULT:', 'FAIL' if gaps else 'PASS')
sys.exit(1 if gaps else 0)
We ran it on eight cards. The surprise is in rows two and three:
| Test card | Result | What the validator printed |
|---|---|---|
| Minimal 1.0 card above | PASS | interface JSONRPC 1.0 https://example.com/a2a/v1 |
Old 0.2.x card (top-level url, protocolVersion: "0.2.6") | PASS | legacy fields mapped into one interface at 0.2.6 |
1.0 card that also keeps an old top-level url | PASS | the old url is dropped without a warning |
No supportedInterfaces, no url | FAIL | card.supportedInterfaces |
No skills | FAIL | card.skills |
No capabilities | FAIL | card.capabilities |
Skill without tags | FAIL | card.skills[0].tags |
version as a number | FAIL | Failed to parse version field |
So the SDK is forgiving about old cards. It does not reject a 0.2.x card. It converts it, keeping protocolVersion at 0.2.6, which tells a 1.0 client the endpoint speaks the old protocol. And when a card has both shapes, the SDK keeps supportedInterfaces and quietly discards the top-level url. If those two URLs differ, the one you edited last may be the one nobody calls.
Path 1: a static file on any host
The simplest route works on GitHub Pages, Netlify, Vercel, Cloudflare and any plain web server.
- Save the card as
agent-card.json. - Put it in a folder named
.well-knowninside the folder your host publishes:public/for most frameworks, the web root for a plain server. - Deploy.
- Run
curl -sI https://yoursite.com/.well-known/agent-card.json. You want200and a JSON content type. - Run the validator against the live URL.
A 404 or 403 at step 4 usually means a build or server rule is hiding dot-folders. Our /.well-known/ 404 fix covers Jekyll on GitHub Pages, the nginx dotfile deny rule and Apache, each tested.
Path 2: Next.js
A static file in public/.well-known/ works as is. Our Next.js 16.4.6 production build served public/.well-known/static-card.json with Content-Type: application/json; charset=UTF-8 and an ETag. Use a route handler when you want your own headers.
- Create
app/.well-known/agent-card.json/route.js. The folder name really isagent-card.json. - Put the card in
app/.well-known/card.jsonand paste:
import card from '../card.json';
export const dynamic = 'force-static';
export function GET() {
return Response.json(card, {
headers: { 'Cache-Control': 'public, max-age=3600', 'Access-Control-Allow-Origin': '*' },
});
}
- Optional, for old clients:
app/.well-known/agent.json/route.jswithreturn Response.redirect(new URL('/.well-known/agent-card.json', request.url), 301);. - Run
npm run build. The route list should show○ /.well-known/agent-card.json(static).
What next start returned on 12 November 2026:
/.well-known/agent-card.json HTTP/1.1 200 OK
content-type: application/json
cache-control: public, max-age=3600
access-control-allow-origin: *
/.well-known/agent.json HTTP/1.1 301 Moved Permanently
location: http://localhost:3101/.well-known/agent-card.json
The validator passed against the live route. The Next.js route handler docs cover the API. More Next.js setup is on the Next.js platform page.
Path 3: Astro
- Put
agent-card.jsoninpublic/.well-known/. Astro 7.4.11 copied it byte for byte todist/.well-known/. - For a second path, add an endpoint at
src/pages/.well-known/agent.json.ts:
import card from '../../../public/.well-known/agent-card.json';
export function GET() {
return new Response(JSON.stringify(card), { headers: { 'Content-Type': 'application/json' } });
}
- Run
npm run build. The log listed/.well-known/agent.jsonas a prerendered route, and both files sat indist/.well-known/. - In
astro preview, both paths returned200,Content-Type: application/jsonand an ETag.
Production headers come from your host, not from Astro, so repeat the curl -sI check after deploy. The Astro endpoints guide explains static endpoints, and the AI readiness setup guide's Astro section collects the rest.
Path 4: Cloudflare, Netlify and Vercel headers
The file goes in the publish folder as in path 1. These snippets add caching and CORS. We did not deploy them live for this post, so verify each with curl -sI after your deploy.
Cloudflare (Workers static assets or Pages): a plain-text _headers file in the static assets folder. According to Cloudflare's headers docs, rules in _headers override what Cloudflare would otherwise send.
/.well-known/agent-card.json
Content-Type: application/json
Cache-Control: public, max-age=3600
Access-Control-Allow-Origin: *
Netlify: the same _headers format in the publish directory, according to Netlify's headers docs. A redirect for old clients goes in _redirects: /.well-known/agent.json /.well-known/agent-card.json 301.
Vercel: in vercel.json. Vercel's permanent: true sends a 308, according to its configuration reference.
{
"headers": [{"source": "/.well-known/agent-card.json",
"headers": [{"key": "Cache-Control", "value": "public, max-age=3600"},
{"key": "Access-Control-Allow-Origin", "value": "*"}]}],
"redirects": [{"source": "/.well-known/agent.json",
"destination": "/.well-known/agent-card.json", "permanent": true}]
}
Path 5: WordPress
WordPress does not need a plugin for this. Its standard rewrite rules send a request to index.php only when no real file matches, so a file you upload is served directly. The WordPress .htaccess reference shows the RewriteCond %{REQUEST_FILENAME} !-f line that makes this work.
- Open your host's file manager or connect over SFTP.
- In the folder that holds
wp-config.php, create.well-knownif it does not exist. Many hosts already have one for SSL validation. - Upload
agent-card.jsoninto it. - Run
curl -sI https://yoursite.com/.well-known/agent-card.json. A 403 means an nginx dotfile rule; the fix is in the 404 guide linked in path 1.
Your agent itself still runs elsewhere. The card only points at it.
Which path should you serve?
Serve agent-card.json as the real file. That is what the spec names, what the SDK fetches, and the only path Cloudflare's checker tried.
For agent.json, our judgement: add it only if you published a card there before, or your logs show requests for it. Then pick one form, and know what each costs.
We pointed the SDK's own A2ACardResolver at the Next.js build above, using a default httpx.AsyncClient() (httpx 0.28.1), which is also what the SDK's ClientFactory creates when you pass no client:
| Request | Server answer | SDK result |
|---|---|---|
/.well-known/agent-card.json | 200, JSON | card parsed, name read |
/.well-known/agent.json | 301 to agent-card.json | AgentCardResolutionError ... (HTTP 301) |
So a redirect keeps one source of truth but does nothing for a client like that one, because httpx follows redirects only when the client is created with follow_redirects=True. A second copy of the same bytes, as in the Astro example, works for every client but has to be kept in sync with the first. If you want old clients served, the copy is the safer of the two. If you only want tidy logs, the redirect is fine.
According to the spec's §8.6, servers should send Cache-Control with a max-age and an ETag derived from the card's version or a hash of its content, and clients should revalidate with If-None-Match. A card changes rarely, so an hour or a day of max-age is reasonable. Static hosts usually add the ETag for you. The Next.js route handler above did not, so add one there if caching matters to you.
Mistakes we saw in the lab
- A 404 page with
text/html. Our four sites (aiscan.site, xspeedcache.com, storeseo.com, bettershield.ai) all returned404 text/htmlfor both paths on 12 November 2026, because none runs an A2A agent. That is correct for a site with no agent. Do not publish a card for an agent that does not exist. - Two shapes in one card. Remove the top-level
urlandprotocolVersionwhen you addsupportedInterfaces. - Empty skills. A card with no skills fails. If the agent does one thing, write one skill.
- Numbers as strings, or strings as numbers.
versionmust be a string.
Next step
Run the AIScan scan on your domain and read P1 to P4 under capabilities: if they pass, your well-known folder works. Then validate the card with the script above. For the neighbouring files, see publish an MCP Server Card, publish an Agent Skills index, and the agentic commerce protocols explainer for where A2A sits next to UCP, ACP and AP2. Every setup guide is on the guides page.
Frequently asked questions
Should my A2A Agent Card be at agent.json or agent-card.json?
Use /.well-known/agent-card.json. The A2A spec named agent.json in version 0.2.x and renamed it to agent-card.json in 0.3.0; version 1.0.0 and the IANA Well-Known URIs registry both use agent-card.json, and a2a-sdk 1.3.0 fetches only that path by default.
My agent card returns 404 after deploy. What is wrong?
Most often the build or the server hid the dot-folder. Jekyll on GitHub Pages skips folders starting with a dot unless you list them under include, and an nginx rule like location ~ /. { deny all; } blocks them. Check that the file is in your built output, then see our /.well-known/ 404 fix guide.
The validator says missing required: card.skills. Why does my card fail?
The A2A 1.0 AgentCard model marks skills as required, and our validator treats an empty list as missing. Add at least one skill with id, name, description and tags, even if the agent does only one thing.
The SDK raised AgentCardResolutionError with HTTP 301. How do I fix it?
The client asked for a path that redirects, and a default httpx.AsyncClient does not follow redirects. Point the client at /.well-known/agent-card.json directly, create the httpx client with follow_redirects=True, or serve a copy of the card at the old path instead of a redirect.
My 0.2.x card still passes the validator. Is it fine to leave it?
It parses, but it is not current. a2a-sdk 1.3.0 converts a top-level url and protocolVersion into one supportedInterfaces entry and keeps the old protocol version, so 1.0 clients see an endpoint that speaks 0.2.6. Rewrite it with supportedInterfaces and protocolVersion 1.0 if your server speaks 1.0.
Does AIScan score the A2A Agent Card?
No. AIScan grades the discovery files around it, P1 API catalog, P2 MCP Server Card, P3 Agent Skills index and P4 OAuth discovery, which tell you whether your /.well-known/ folder is reachable. Cloudflare's Is It Agent Ready runs an a2aAgentCard check if you want the card itself tested.
What content type should the agent card use?
application/json. Our Next.js 16.4.6 route handler and Astro 7.4.11 preview both returned application/json, and Next.js static files added charset=UTF-8. If curl -sI shows text/html, you are almost certainly looking at a 404 page, not your card.
Do I need an agent card if my site has no AI agent?
No. The card describes an A2A agent you run, with the URL where it accepts requests. A content site with no agent should return 404 at this path, which is what our four sites do; publishing a card for an agent that does not exist sends clients to a dead endpoint.

About the author
M Asif Rahman
Entrepreneur, curious about AI
I have been building companies for a long time, and lately most of my curiosity goes to AI. AIScan is a small personal project I built on a flight from Dubai to Narita. I use it to understand how ready the web is for AI agents, and I share what the scans teach me.
Related guides
Publish an MCP Server Card So Agents Can Find Your Tools
On 10 September 2026 we probed every host running a remote MCP server listed in the official MCP Registry: 79 domains, three requests each. Seven of them publish a parseable server card at…
Set X-Robots-Tag or a Canonical Link Header on .md Copies and llms.txt: Nginx, Apache, Cloudflare, Vercel, Netlify, Next.js, Astro
Send one header on each .md copy and nothing new on the HTML: Link: <https://example.com/page; rel="canonical". On /llms.txt, which has no HTML twin, the only header that applies is XRobotsTag:…
AI readiness setup guide by platform (2026)
Verified 17, 18, 20 and 22 October 2026. Updated on 22 October 2026. An AIready site needs six things: a robots.txt that names AI crawlers D1, B2, a sitemap D2, a declared feed E5, an llms.txt C2,…
