Tutorial: publish your first Agent Card
Write a minimal A2A v1.0 Agent Card, serve it at /.well-known/agent-card.json with the right headers, and check it with the Emissar validator.
To publish your first Agent Card, write a JSON file with the eight fields A2A v1.0 requires, serve it over HTTPS at /.well-known/agent-card.json with Content-Type: application/json, a Cache-Control max-age and an ETag, and check it with a validator before you tell anyone it exists.
This tutorial does all three. It shows two ways to serve the card, as a static file and from a Cloudflare Worker, and it uses the real output of each command, captured on September 26, 2026. Every snippet was run as shown.
What you need
- A domain you control, where the card will live.
- The URL of your A2A endpoint, or a plan to build one. Tutorial: build an A2A server on Cloudflare Workers builds a small one.
- For the hosting examples: Node.js and npm. Both examples use Wrangler, Cloudflare’s command-line tool, and run locally without a Cloudflare account. Deploying needs one.
Step 1: Write a minimal v1.0 card
Save this as agent-card.json. It describes an illustrative support agent that answers order-status questions.
{
"name": "Example Outfitters Support",
"description": "Answers order status questions for Example Outfitters customers. Send an order number such as A-1001.",
"version": "1.0.0",
"provider": {
"organization": "Example Outfitters",
"url": "https://example.com"
},
"supportedInterfaces": [
{
"url": "https://example.com/a2a/v1",
"protocolBinding": "JSONRPC",
"protocolVersion": "1.0"
}
],
"capabilities": {
"streaming": false,
"pushNotifications": false
},
"defaultInputModes": ["text/plain", "application/json"],
"defaultOutputModes": ["text/plain"],
"skills": [
{
"id": "order-status",
"name": "Order status",
"description": "Returns the shipping status of one order. Accepts the order number as text or as a data part {\"orderId\": \"A-1001\"}.",
"tags": ["orders", "shipping", "support"],
"examples": ["Where is order A-1001?", "{\"orderId\":\"A-1001\"}"]
}
]
}
What each part does:
| Field | Required | Why it is set this way |
|---|---|---|
name, description |
Yes | Read by people deciding to integrate and by models deciding to delegate |
version |
Yes | Your agent’s version, separate from the protocol version |
provider |
No | Names the organization; recommended, but it is only a claim |
supportedInterfaces |
Yes | Where to connect. protocolVersion is Major.Minor, so "1.0", never "1.0.0" |
capabilities |
Yes | Optional features. With streaming and pushNotifications false, the server must refuse those operations |
defaultInputModes, defaultOutputModes |
Yes | Media types, in type/subtype form |
skills |
Yes | Each needs id, name, description and tags; examples are optional but useful |
Replace example.com with your domain and endpoint. Keep secrets out: anyone can read this file. Agent Cards explained covers every field, including authentication.
Step 2: Serve it at the well-known path
RFC 8615 reserves the /.well-known/ path prefix for site metadata, and A2A registers agent-card.json under it. The file must be reachable at exactly:
https://your-domain/.well-known/agent-card.json
The A2A specification says servers should send Cache-Control with a max-age and an ETag derived from the card’s version or content, so clients can cache the card and revalidate it cheaply.
Two of the headers are choices rather than requirements. max-age=3600 lets clients reuse the card for an hour; shorten it while you are still changing the card, and lengthen it once the card is stable. Access-Control-Allow-Origin: * lets browser-based tools and clients read the card from other origins. The card is public by design, so allowing any origin exposes nothing new.
Option A: a static file
This example uses Cloudflare Workers static assets. Put the card in a .well-known folder inside your assets directory, next to a _headers file:
my-site/
wrangler.jsonc
public/
_headers
.well-known/
agent-card.json
public/_headers sets the caching header and lets browser-based clients read the card:
/.well-known/agent-card.json
Cache-Control: public, max-age=3600
Access-Control-Allow-Origin: *
wrangler.jsonc points Wrangler at the assets folder:
{
"name": "example-agent-card",
"compatibility_date": "2026-09-26",
"assets": {
"directory": "./public/"
}
}
Install Wrangler and start a local server:
npm install --save-dev wrangler
npx wrangler dev --port 8791
In a second terminal, check the headers:
curl -s -D - -o /dev/null http://127.0.0.1:8791/.well-known/agent-card.json
HTTP/1.1 200 OK
Content-Length: 971
Content-Type: application/json
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=3600
ETag: "87ccb8265a87c87085068cf68ebdb44d"
CF-Cache-Status: HIT
Wrangler sets Content-Type from the .json extension. Cloudflare’s documentation says static assets get an ETag built from a hash of the file, and a default Cache-Control of public, max-age=0, must-revalidate, which the _headers rule replaces. The _headers file itself is not served. A conditional request with that ETag returns 304 with no body:
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'If-None-Match: "87ccb8265a87c87085068cf68ebdb44d"' \
http://127.0.0.1:8791/.well-known/agent-card.json
304
Deploy with npx wrangler deploy when you are ready (this needs a Cloudflare account; we ran only the local steps). On any other static host, check two things: that your build copies the .well-known folder (folders that start with a dot are easy to lose), and that you can set Content-Type and Cache-Control for that path in the host’s configuration.
Option B: a Cloudflare Worker
Serving the card from code gives you full control of the headers, including a content-based ETag and 304 handling. Project layout:
card-worker/
wrangler.jsonc
src/
index.ts
agent-card.json
src/agent-card.json is the card from step 1. src/index.ts:
import card from "./agent-card.json";
const body = JSON.stringify(card, null, 2);
let etag: string | undefined;
// ETag from a hash of the card, so it changes whenever the card does.
async function cardEtag(): Promise<string> {
if (!etag) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
etag = `"${hex.slice(0, 32)}"`;
}
return etag;
}
export default {
async fetch(request: Request): Promise<Response> {
const { pathname } = new URL(request.url);
if (pathname !== "/.well-known/agent-card.json") return new Response("Not found", { status: 404 });
if (request.method !== "GET" && request.method !== "HEAD") {
return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
}
const headers = {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=3600",
"ETag": await cardEtag(),
"Access-Control-Allow-Origin": "*",
};
if (request.headers.get("If-None-Match") === headers.ETag) return new Response(null, { status: 304, headers });
return new Response(request.method === "HEAD" ? null : body, { headers });
},
};
wrangler.jsonc:
{
"name": "example-agent-card",
"main": "src/index.ts",
"compatibility_date": "2026-09-26"
}
Run it and check the same three things: headers, a conditional request, and a method the route doesn’t allow.
npx wrangler dev --port 8791
curl -s -D - -o /dev/null http://127.0.0.1:8791/.well-known/agent-card.json
HTTP/1.1 200 OK
Content-Length: 1046
Content-Type: application/json
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=3600
ETag: "899304ac9b5ffb930af33e0d9dba5da9"
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'If-None-Match: "899304ac9b5ffb930af33e0d9dba5da9"' \
http://127.0.0.1:8791/.well-known/agent-card.json
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8791/.well-known/agent-card.json
304
405
The body is larger than in option A because the Worker pretty-prints the JSON. Because the ETag is a hash of the card, editing the card changes the ETag even if you forget to bump version.
What a client does with the card
Before you validate, it helps to know how the card is read. A typical A2A client:
- fetches
https://your-domain/.well-known/agent-card.jsonand caches it according to your headers; - reads
capabilitiesto learn which optional operations it may call; - walks
supportedInterfacesin order and picks the first binding and protocol version it supports; - obtains any credentials that
securitySchemesandsecurityRequirementsask for; - sends
SendMessageto that interface’s URL with anA2A-Versionheader matching the interface’sprotocolVersion.
Every step depends on a field in your card. A wrong binding name or a missing interface stops the client at step 3, and an unreachable URL stops it at step 5. That is why the validator checks the card’s structure first and your HTTP headers once the card is live.
Step 3: Check it with the validator
The Emissar Agent Card validator accepts a domain, an HTTPS URL, or pasted card JSON. A card on localhost isn’t reachable from the internet, so paste it while you work locally. Pasted JSON is checked as it is; results are kept for 24 hours so you can share the link.
The same check is available as an API. Wrap the card in {"input": "..."} and post it:
python -c "import json; print(json.dumps({'input': open('agent-card.json').read()}))" > payload.json
curl -s -X POST https://emissar.ai/api/tools/validate-card \
-H 'Content-Type: application/json' --data-binary @payload.json
The response is JSON with a version, a summary and a list of checks. For the step 1 card, served by the Worker and pasted in, the result was version 1.0, 9 passed, 1 warning, 0 failed:
| Level | Check |
|---|---|
| pass | name: Example Outfitters Support |
| pass | description |
| pass | version: 1.0.0 |
| pass | Preferred interface: JSONRPC, protocol 1.0, at https://example.com/a2a/v1 |
| pass | capabilities: no optional capabilities on |
| pass | defaultInputModes: text/plain, application/json |
| pass | defaultOutputModes: text/plain |
| pass | Skill “order-status”: Order status (3 tags; 2 examples) |
| pass | provider: Example Outfitters (https://example.com) |
| warn | The card isn’t signed, so clients can’t check that it wasn’t altered after publication |
The warning is expected: signing is optional. See Signed Agent Cards when you are ready.
Once the card is live, run the validator again with just your domain. It then fetches the card from the well-known path and also checks HTTPS, Content-Type, Cache-Control and ETag.
Step 4: Fix common errors
To see what goes wrong most often, we pasted a card with five typical mistakes:
{
"name": "Example Outfitters Support",
"description": "Answers order status questions.",
"version": "1.0.0",
"supportedInterfaces": [
{
"url": "http://example.com/a2a/v1",
"protocolBinding": "jsonrpc",
"protocolVersion": "1.0.0"
}
],
"capabilities": {},
"defaultInputModes": ["text"],
"skills": [
{ "id": "order-status", "name": "Order status", "description": "Returns the status of one order." }
]
}
The validator returned 4 passed, 4 warnings and 4 failures. Its messages, and the fixes:
| Level | Validator message (shortened) | Fix |
|---|---|---|
| fail | protocolBinding “jsonrpc” should be written exactly “JSONRPC” | Core bindings are case-sensitive: JSONRPC, GRPC, HTTP+JSON |
| warn | No interface uses a core binding (JSONRPC, GRPC, HTTP+JSON) | Follows from the line above |
| fail | url uses http://, not https://. Production deployments MUST use HTTPS | Use an https:// URL |
| warn | protocolVersion “1.0.0” includes a patch number. Use Major.Minor (“1.0”) | Write "1.0" |
| warn | defaultInputModes has values that aren’t media types (type/subtype): text | Write text/plain |
| fail | defaultOutputModes is missing. It is required | Add it, for example ["text/plain"] |
| fail | skills[0].tags is missing. It is required | Add at least one tag |
| warn | The card isn’t signed | Optional; sign when needed |
Two more mistakes we tested:
- Publishing a v0.3 card. A card with top-level
url,preferredTransportandprotocolVersion: "0.3.0"validated as version0.3with the warning that v1.0 clients readsupportedInterfacesfrom/.well-known/agent-card.json. Publish a v1.0 card at that path. - Invalid JSON. A trailing comma made the API answer HTTP 400 with the parser’s message, “Expected double-quoted property name in JSON at position 39”. Run your card through a JSON parser before anything else.
Other problems the validator can only see when it fetches by domain: a card served as text/plain or text/html, a missing Cache-Control max-age, and a missing ETag.
Next steps
- Make sure the endpoint in
supportedInterfacesanswersSendMessageandGetTask. The Cloudflare Workers server tutorial builds one and tests every error path. - Add authentication with
securitySchemesandsecurityRequirementsbefore you expose anything that touches customer accounts. - Sign the card if your callers need to verify it.
- Bump
versionwhenever the card changes, and keep the validator result link with your release notes.
Questions
- Should I publish the card before my A2A endpoint works?
- No. Clients that find the card will call the URL in supportedInterfaces. Publish the card when that endpoint answers SendMessage and GetTask, or build both together as in the Cloudflare Workers server tutorial.
- Can the endpoint live on a different host from the card?
- Yes. The card sits at the well-known path of the domain clients already know, and each supportedInterfaces entry carries its own absolute URL, which can point to another host, such as an API subdomain.
- Why did the validator say my card is unsigned?
- Signing is optional in A2A, so the validator reports it as a warning, not a failure. Sign the card when callers need to check that it came from you and was not altered. The Signed Agent Cards guide shows how.
Sources
- A2A Protocol Specification (sections 3.3.4, 4.4, 8.2 and 8.6) (accessed )
- A2A normative protocol definition (a2a.proto, AgentCard) (accessed )
- RFC 8615: Well-Known Uniform Resource Identifiers (URIs) (accessed )
- Headers (Cloudflare Workers static assets documentation) (accessed )
- Configuration and Bindings (Cloudflare Workers static assets documentation) (accessed )
- Emissar Agent Card validator (accessed )