Learn

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:

  1. fetches https://your-domain/.well-known/agent-card.json and caches it according to your headers;
  2. reads capabilities to learn which optional operations it may call;
  3. walks supportedInterfaces in order and picks the first binding and protocol version it supports;
  4. obtains any credentials that securitySchemes and securityRequirements ask for;
  5. sends SendMessage to that interface’s URL with an A2A-Version header matching the interface’s protocolVersion.

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, preferredTransport and protocolVersion: "0.3.0" validated as version 0.3 with the warning that v1.0 clients read supportedInterfaces from /.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

  1. Make sure the endpoint in supportedInterfaces answers SendMessage and GetTask. The Cloudflare Workers server tutorial builds one and tests every error path.
  2. Add authentication with securitySchemes and securityRequirements before you expose anything that touches customer accounts.
  3. Sign the card if your callers need to verify it.
  4. Bump version whenever 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

  1. A2A Protocol Specification (sections 3.3.4, 4.4, 8.2 and 8.6) (accessed )
  2. A2A normative protocol definition (a2a.proto, AgentCard) (accessed )
  3. RFC 8615: Well-Known Uniform Resource Identifiers (URIs) (accessed )
  4. Headers (Cloudflare Workers static assets documentation) (accessed )
  5. Configuration and Bindings (Cloudflare Workers static assets documentation) (accessed )
  6. Emissar Agent Card validator (accessed )