---
title: "Publish an A2A Agent Card at /.well-known/agent-card.json, Tested on Every Stack"
slug: a2a-agent-card-well-known
published: 2026-11-12T08:14:58.34903+00:00
updated: 2026-11-12T08:16:39.436084+00:00
author: "M Asif Rahman"
author_url: https://ar.bd
category: "AI Readiness"
tags: A2A, agent card, well-known, agent discovery, AI agents, check:P2, platform:nextjs, platform:astro, platform:cloudflare
description: "Where an A2A Agent Card goes (agent-card.json, not agent.json), the 8 required fields, and setup for Next.js, Astro, Cloudflare, Netlify, Vercel and WordPress."
url: https://aiscan.site/blog/a2a-agent-card-well-known
---

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](https://ar.bd). 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](https://www.rfc-editor.org/rfc/rfc8615) 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](https://a2a-protocol.org/latest/specification/), §14.3 registers the suffix `agent-card.json` with status Permanent, and the [IANA Well-Known URIs registry](https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml) 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](https://aiscan.site/blog/llms-txt-vs-robots-txt-vs-sitemap) 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:

```json
{
  "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](https://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](https://aiscan.site/docs/checks/capabilities). 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](https://isitagentready.com/) 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:

```python
#!/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.

1. Save the card as `agent-card.json`.
2. Put it in a folder named `.well-known` inside the folder your host publishes: `public/` for most frameworks, the web root for a plain server.
3. Deploy.
4. Run `curl -sI https://yoursite.com/.well-known/agent-card.json`. You want `200` and a JSON content type.
5. 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](https://aiscan.site/blog/well-known-folder-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.

1. Create `app/.well-known/agent-card.json/route.js`. The folder name really is `agent-card.json`.
2. Put the card in `app/.well-known/card.json` and paste:

```js
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': '*' },
  });
}
```

3. Optional, for old clients: `app/.well-known/agent.json/route.js` with `return Response.redirect(new URL('/.well-known/agent-card.json', request.url), 301);`.
4. Run `npm run build`. The route list should show `○ /.well-known/agent-card.json` (static).

What `next start` returned on 12 November 2026:

```text
/.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](https://nextjs.org/docs/app/api-reference/file-conventions/route) cover the API. More Next.js setup is on the [Next.js platform page](https://aiscan.site/docs/platforms/nextjs).

## Path 3: Astro

1. Put `agent-card.json` in `public/.well-known/`. Astro 7.4.11 copied it byte for byte to `dist/.well-known/`.
2. For a second path, add an endpoint at `src/pages/.well-known/agent.json.ts`:

```ts
import card from '../../../public/.well-known/agent-card.json';
export function GET() {
  return new Response(JSON.stringify(card), { headers: { 'Content-Type': 'application/json' } });
}
```

3. Run `npm run build`. The log listed `/.well-known/agent.json` as a prerendered route, and both files sat in `dist/.well-known/`.
4. In `astro preview`, both paths returned `200`, `Content-Type: application/json` and an ETag.

Production headers come from your host, not from Astro, so repeat the `curl -sI` check after deploy. The [Astro endpoints guide](https://docs.astro.build/en/guides/endpoints/) explains static endpoints, and the [AI readiness setup guide's Astro section](https://aiscan.site/blog/ai-readiness-setup-guide#astro) 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](https://developers.cloudflare.com/workers/static-assets/headers/), rules in `_headers` override what Cloudflare would otherwise send.

```text
/.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](https://docs.netlify.com/manage/routing/headers/). 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](https://vercel.com/docs/project-configuration/vercel-json).

```json
{
  "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](https://developer.wordpress.org/advanced-administration/server/web-server/httpd/) shows the `RewriteCond %{REQUEST_FILENAME} !-f` line that makes this work.

1. Open your host's file manager or connect over SFTP.
2. In the folder that holds `wp-config.php`, create `.well-known` if it does not exist. Many hosts already have one for SSL validation.
3. Upload `agent-card.json` into it.
4. 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 returned `404 text/html` for 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 `url` and `protocolVersion` when you add `supportedInterfaces`.
- **Empty skills.** A card with no skills fails. If the agent does one thing, write one skill.
- **Numbers as strings, or strings as numbers.** `version` must be a string.

## Next step

Run [the AIScan scan](https://aiscan.site/) 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](https://aiscan.site/blog/publish-mcp-server-card), [publish an Agent Skills index](https://aiscan.site/blog/publish-agent-skills-index), and [the agentic commerce protocols explainer](https://aiscan.site/blog/agentic-commerce-protocols-explained) for where A2A sits next to UCP, ACP and AP2. Every setup guide is on [the guides page](https://aiscan.site/guides).

