A2A Agent Card Implementation Guide: Build, Validate, Sign
This guide takes you from an empty file to an Agent Card you can publish with confidence. It assumes you know what an Agent Card is; if not, start with Agent Card . Field-by-field explanations stay on the concept pages. This page adds what they do not have: the decisions you make while filling in a card, a validator you can run, and a map from runtime errors back to card mistakes.
Everything is checked against A2A v1.0.1: the a2a.proto at that tag, the specification, and the specification’s own sample cards. Where the specification and the proto disagree, the proto wins (specification section 1.4).
The path
- Declare interfaces: where and how clients reach the agent.
- Declare capabilities: what you promise, and the errors you owe when you do not.
- Describe skills.
- Declare security.
- Validate: run the checker until it prints
OK: no findings. - Sign (optional), then publish and cache.
Signing comes last because a signature covers the final content: any edit after signing invalidates it.
1. Declare interfaces
supportedInterfaces is a list of entries with url, protocolBinding, and protocolVersion, all three required. The list is read in order, so its order is part of the contract.
| Your situation | Declare | Rule and source |
|---|---|---|
| One endpoint, one transport | One entry | The three fields are REQUIRED in a2a.proto |
| Several transports | One entry per binding and URL, most preferred first | The list SHOULD declare all supported combinations in preference order, and the first entry is the preferred interface (section 8.3.1) |
| Several transports on one endpoint | Repeat the same url once per binding | URLs MAY be reused (section 8.3.1) |
| Many agents or tenants behind one endpoint | Set tenant on the entry | Clients MUST copy it into every request message; the protocol does not define its format, and the server decides what it means (AgentInterface.tenant, section 8.3.2). See Multi-Tenancy |
A binding other than JSONRPC, GRPC, or HTTP+JSON | Use a URI as protocolBinding | Custom bindings SHOULD be identified by a URI, and a breaking change to a binding MUST get a new URI (section 5.8). See Custom Protocol Bindings |
| Local development | A plain http:// URL | The proto requires an absolute HTTPS URL in production, so the validator warns until you switch |
2. Declare capabilities
Capabilities are a contract. When a capability is false or absent and a client uses it anyway, the agent MUST answer with a specific error (section 3.3.4). Declare true only for what you have built.
| Capability | Declare true when | If false or absent, the agent MUST return |
|---|---|---|
streaming | SendStreamingMessage and SubscribeToTask work | UnsupportedOperationError for both |
pushNotifications | Create, Get, List, and Delete of push notification configs work | PushNotificationNotSupportedError |
extendedAgentCard | An authenticated GetExtendedAgentCard returns a configured card | UnsupportedOperationError; and if you declare true but configured no extended card, ExtendedAgentCardNotConfiguredError |
extensions[] with required: true | Your agent cannot work without the extension | ExtensionSupportRequiredError when the server requests the extension and the client did not declare support for it |
Error names and their JSON-RPC, gRPC, and HTTP mappings are in A2A Error Codes
. Clients name the extensions they want to use in each request through the A2A-Extensions service parameter, a comma-separated list of extension URIs; see Extensions
.
streaming, pushNotifications, and extendedAgentCard are optional booleans. Section 3.3.4 treats false and absent the same, but signing does not: an optional field you wrote explicitly, even as false, belongs to the signed content, and one you omitted does not (section 8.4.1). Choose one style and keep it identical between signing and serving.
3. Describe skills
A skill needs id, name, description, and at least one entry in tags; examples, inputModes, outputModes, and securityRequirements are optional. Three checks are worth running on every card:
- A card with an empty
skillslist is invalid, and so is a skill with notags. Arrays marked requiredMUSThold at least one element (section 5.7). - Keep
idunique across the card. The proto describes it as a unique identifier, and the validator warns on duplicates. - A skill’s
inputModesandoutputModesreplace the card defaults for that skill, so declare them accurately.
Skills describe the agent to whoever reads the card; no A2A operation selects one. The details, with the specification’s example, are on AgentSkill .
4. Declare security
securitySchemes is a map from a name you choose to a scheme, and each scheme sets exactly one of five keys: apiKeySecurityScheme, httpAuthSecurityScheme, oauth2SecurityScheme, openIdConnectSecurityScheme, or mtlsSecurityScheme. securityRequirements lists which schemes, and which scopes, a client must satisfy:
"securityRequirements": [{ "schemes": { "google": { "list": ["openid", "profile", "email"] } } }]- Every name used in a requirement, on the card or on a skill, must be defined in
securitySchemes. - An
oauth2SecuritySchemesets exactly one flow. Do not declareimplicitorpasswordin new cards: the v1.0.1 proto marks bothdeprecated. - If you set
extendedAgentCard: true, declare at least one scheme. The extended card is for authenticated clients, and with no scheme there is nothing for them to authenticate with. - The required fields inside each scheme and flow are in the table at the end .
The scheme types and OAuth 2.0 flows are explained on SecurityScheme .
5. Validate the card
Save this as agent-card-lint.jq. It reads only the card, makes no network calls, and applies the rules from the previous sections plus the REQUIRED fields of a2a.proto at v1.0.1.
# Agent Card lint for A2A v1.0.1.
# Usage: jq -r -f agent-card-lint.jq agent-card.json
# Checks come from the REQUIRED annotations in a2a.proto, specification sections 5.7, 8.3 and 8.4,
# and the v0.3 -> v1.0 renames. ERROR lines break the specification; WARN lines are advisory.
def blank: . == null or . == "" or . == [];
# Required strings, arrays (at least one element, section 5.7) and objects.
def need($o; $path; $strs; $arrs; $objs):
if ($o | type) != "object" then "ERROR \($path): expected a JSON object"
else
($strs[] as $k | select($o[$k] | blank) | "ERROR \($path)/\($k): required string is missing or empty"),
($arrs[] as $k | select(($o[$k] | type) != "array" or ($o[$k] | length) == 0)
| "ERROR \($path)/\($k): required array is missing or empty"),
($objs[] as $k | select($o[$k] == null) | "ERROR \($path)/\($k): required object is missing")
end;
def v03: {
"url": "supportedInterfaces[0].url",
"preferredTransport": "supportedInterfaces[].protocolBinding",
"additionalInterfaces": "supportedInterfaces[]",
"protocolVersion": "supportedInterfaces[].protocolVersion",
"supportsAuthenticatedExtendedCard": "capabilities.extendedAgentCard",
"security": "securityRequirements"
};
def card_fields: ["name", "description", "supportedInterfaces", "provider", "version", "documentationUrl",
"capabilities", "securitySchemes", "securityRequirements", "defaultInputModes", "defaultOutputModes",
"skills", "signatures", "iconUrl"];
# Scheme type -> [required strings, required objects]; flow type -> same.
def scheme_req: {
"apiKeySecurityScheme": [["location", "name"], []],
"httpAuthSecurityScheme": [["scheme"], []],
"oauth2SecurityScheme": [[], ["flows"]],
"openIdConnectSecurityScheme": [["openIdConnectUrl"], []],
"mtlsSecurityScheme": [[], []]
};
def flow_req: {
"authorizationCode": [["authorizationUrl", "tokenUrl"], ["scopes"]],
"clientCredentials": [["tokenUrl"], ["scopes"]],
"deviceCode": [["deviceAuthorizationUrl", "tokenUrl"], ["scopes"]],
"implicit": [[], []],
"password": [[], []]
};
def requirement_names($path):
(. // []) | to_entries[] | .key as $i | .value
| if (type == "object" and (.schemes | type) == "object") then (.schemes | keys[] | {name: ., at: "\($path)/\($i)"})
else {bad: "\($path)/\($i)"} end;
. as $card
| if type != "object" then "ERROR /: an Agent Card must be a JSON object" else
[
need($card; ""; ["name", "description", "version"];
["supportedInterfaces", "defaultInputModes", "defaultOutputModes", "skills"]; ["capabilities"]),
(keys[] | select(. as $k | card_fields | index($k) | not) | . as $k
| if v03[$k] then "ERROR /\($k): v0.3 field, use \(v03[$k]) in v1.0"
else "WARN /\($k): not an AgentCard field in v1.0.1 (typo?)" end),
(($card.supportedInterfaces // []) | to_entries[] | .key as $i | .value as $v
| need($v; "/supportedInterfaces/\($i)"; ["url", "protocolBinding", "protocolVersion"]; []; []),
(select(($v | type) == "object") | $v
| (select((.url // "https://") | startswith("https://") | not)
| "WARN /supportedInterfaces/\($i)/url: production URLs must be absolute HTTPS"),
(select((.protocolBinding // "JSONRPC") as $b | ["JSONRPC", "GRPC", "HTTP+JSON"] | index($b) == null and ($b | test("^[a-z][a-z0-9+.-]*:") | not))
| "WARN /supportedInterfaces/\($i)/protocolBinding: not JSONRPC, GRPC or HTTP+JSON; custom bindings should use a URI (section 5.8)"),
(select((.protocolVersion // "1.0") | test("^[0-9]+\\.[0-9]+$") | not)
| "WARN /supportedInterfaces/\($i)/protocolVersion: expected major.minor such as 1.0"))),
(select($card.provider != null) | need($card.provider; "/provider"; ["url", "organization"]; []; [])),
(($card.skills // []) | to_entries[] | .key as $i | .value as $s
| need($s; "/skills/\($i)"; ["id", "name", "description"]; ["tags"]; []),
(select(($s | type) == "object" and ($s | has("security"))) | "ERROR /skills/\($i)/security: v0.3 field, use securityRequirements in v1.0")),
((($card.skills // []) | map(select(type == "object") | .id) | group_by(.)[] | select(length > 1 and .[0] != null) | .[0])
| "WARN /skills: id \"\(.)\" is used more than once"),
(($card.securitySchemes // {}) | to_entries[] | .key as $n | .value as $v
| "/securitySchemes/\($n)" as $p
| ($v | if type == "object" then keys else [] end) as $ks
| if ($ks | length) != 1 or (scheme_req | has($ks[0]) | not)
then "ERROR \($p): set exactly one of \(scheme_req | keys_unsorted | join(", "))"
else $ks[0] as $t
| need($v[$t]; "\($p)/\($t)"; scheme_req[$t][0]; []; scheme_req[$t][1]),
(select($t == "oauth2SecurityScheme" and ($v[$t].flows | type) == "object")
| ($v[$t].flows | keys) as $fk
| "\($p)/\($t)/flows" as $fp
| if ($fk | length) != 1 or (flow_req | has($fk[0]) | not)
then "ERROR \($fp): set exactly one of \(flow_req | keys_unsorted | join(", "))"
else need($v[$t].flows[$fk[0]]; "\($fp)/\($fk[0])"; flow_req[$fk[0]][0]; []; flow_req[$fk[0]][1]),
(select(["implicit", "password"] | index($fk[0])) | "WARN \($fp)/\($fk[0]): deprecated in v1.0.1")
end)
end),
([$card.securityRequirements | requirement_names("/securityRequirements"),
(($card.skills // []) | to_entries[] | .key as $i | .value | select(type == "object")
| .securityRequirements | requirement_names("/skills/\($i)/securityRequirements"))][]
| if .bad then "ERROR \(.bad): a requirement is an object with a \"schemes\" map"
elif (.name as $n | ($card.securitySchemes // {}) | has($n) | not) then "ERROR \(.at): scheme \"\(.name)\" is not defined in securitySchemes"
else empty end),
(select($card.capabilities.extendedAgentCard == true and (($card.securitySchemes // {}) | length) == 0)
| "WARN /capabilities/extendedAgentCard: GetExtendedAgentCard needs authentication, but no securitySchemes are declared"),
(($card.capabilities.extensions // []) | to_entries[] | select(.value.uri | blank)
| "WARN /capabilities/extensions/\(.key): extension without a uri"),
(($card.signatures // []) | to_entries[] | .key as $i | .value as $g
| need($g; "/signatures/\($i)"; ["protected", "signature"]; []; []),
(select(($g | type) == "object" and ($g.protected | blank | not)) | $g.protected
| (try (gsub("-"; "+") | gsub("_"; "/") | @base64d | fromjson) catch null) as $h
| if $h == null then "ERROR /signatures/\($i)/protected: not base64url-encoded JSON"
else (("alg", "kid") as $k | select($h[$k] | blank) | "ERROR /signatures/\($i)/protected: header needs \($k)"),
(select($h.typ != "JOSE") | "WARN /signatures/\($i)/protected: typ should be JOSE") end))
] | if length == 0 then "OK: no findings" else .[] end
endRun it on your card. ERROR lines mean the card is invalid or cannot work as written: a missing required field, a v0.3 leftover, a reference to something that is not declared. WARN lines are advisory.
jq -r -f agent-card-lint.jq agent-card.jsonIn CI, fail the build on any ERROR:
if jq -r -f agent-card-lint.jq agent-card.json | grep -q '^ERROR'; then exit 1; fiWhat it reports on the official sample cards
To get a card to try, copy the JSON from section 8.5 of the specification, or extract it from a clone of a2aproject/A2A :
git show v1.0.1:docs/specification.md \
| awk '/^### 8\.5\. Sample Agent Card/{f=1} f&&/^```json/{g=1;next} g&&/^```/{exit} g' > agent-card.jsonRun against two copies of that sample, the validator gives different answers:
| Card | Result |
|---|---|
Section 8.5 sample at tag v1.0.1 | ERROR /security: v0.3 field, use securityRequirements in v1.0 |
Section 8.5 sample on main (commit 98279d6, 2026-10-20) | OK: no findings |
The two samples differ in exactly one line: the tag still writes the security requirement in the v0.3 shape, and main corrected it. If you copy a card from an older copy of the specification, check that field first. SecurityScheme
covers this in more detail.
Break it on purpose
A checker is only useful if it fails when it should. Each row edits the main sample in one place and shows the single finding that results:
jq '<edit>' agent-card.json | jq -r -f agent-card-lint.jq| Edit | Finding |
|---|---|
del(.name) | ERROR /name: required string is missing or empty |
del(.skills[0].tags) | ERROR /skills/0/tags: required array is missing or empty |
.supportedInterfaces[0].protocolBinding="REST" | WARN /supportedInterfaces/0/protocolBinding: not JSONRPC, GRPC or HTTP+JSON; custom bindings should use a URI (section 5.8) |
.securityRequirements=[{"schemes":{"nope":{"list":[]}}}] | ERROR /securityRequirements/0: scheme "nope" is not defined in securitySchemes |
.securityRequirements=[{"google":["openid"]}] | ERROR /securityRequirements/0: a requirement is an object with a "schemes" map |
.securitySchemes.google.openIdConnectSecurityScheme={} | ERROR /securitySchemes/google/openIdConnectSecurityScheme/openIdConnectUrl: required string is missing or empty |
.capabilities.extensions=[{"required":true}] | WARN /capabilities/extensions/0: extension without a uri |
.signatures[0].protected="@@@" | ERROR /signatures/0/protected: not base64url-encoded JSON |
What it does not check
- That the URLs answer, or that the server really implements what the card declares.
- The cryptographic signature. It checks that the
protectedheader decodes and hasalgandkid, nothing more. - Rules from after v1.0.1. The program is written for the
v1.0.1proto and was tested with jq 1.8.1.
Migrating a v0.3 card
The validator names every v0.3 field it finds and the v1.0 field that replaces it:
| v0.3 field | v1.0 replacement |
|---|---|
url | supportedInterfaces[0].url |
preferredTransport | supportedInterfaces[].protocolBinding |
additionalInterfaces | supportedInterfaces[] |
protocolVersion | supportedInterfaces[].protocolVersion |
supportsAuthenticatedExtendedCard | capabilities.extendedAgentCard |
security (card and skill) | securityRequirements |
The same fields are in the migration table on Agent Card , and the protobuf field numbers are in Agent Card Proto Field Numbers .
6. Sign the card (optional)
A signature lets a client confirm the card is authentic and unchanged. The specification’s rules (section 8.4):
- Canonicalize the card with the JSON Canonicalization Scheme (RFC 8785), then sign it as a JSON Web Signature (RFC 7515).
- Exclude
signaturesfrom the signed content. - Respect field presence: omit optional fields you did not set and fields holding default values, but always keep
REQUIREDfields and optional fields you set explicitly. - Put
alg,typ, andkidin the protected header;typSHOULDbeJOSE. The header may also carryjku, the URL of the key set.
The one step you can check without a crypto library is the canonical form. This jq command applies the exclusion and the key ordering, and removes the empty extensions array that the presence rule drops:
jq -S -c 'del(.signatures) | del(.capabilities.extensions | select(. == []))' agent-card.jsonRun on the minimal card from the specification’s canonicalization example (the same card is on Agent Card ), it prints exactly the canonical string the specification gives:
{"capabilities":{"pushNotifications":false,"streaming":false},"defaultInputModes":["text/plain"],"defaultOutputModes":["text/plain"],"description":"An example agent that echoes messages.","name":"Example Agent","skills":[{"description":"Echoes the input.","id":"echo","name":"Echo","tags":["echo"]}],"supportedInterfaces":[{"protocolBinding":"JSONRPC","protocolVersion":"1.0","url":"https://example.com/a2a/v1"}],"version":"1.0.0"}That example is in the main branch of the specification; the v1.0.1 tag uses a shorter fragment, and the same command reproduces its expected output as well.
Use this only as a quick diff between what you signed and what you serve. jq is not an RFC 8785 implementation. On jq 1.8.1 it prints 1e2 as 1E+2 where RFC 8785 gives 100, keeps 1.0 where RFC 8785 gives 1, and orders keys by code point where RFC 8785 orders by UTF-16 code unit, so a key containing U+FFFF sorts before one containing an emoji, the opposite of RFC 8785. Agent Cards seldom hold numbers, but AgentExtension.params is a free-form structure and can. Produce the real signature with a JCS library and a JWS library for your language.
7. Publish and cache
Serve the card at https://{server_domain}/.well-known/agent-card.json (section 8.2). Agent Cards change rarely, so the specification asks servers to help clients cache them (section 8.6.1):
- Send
Cache-Controlwith amax-agethat matches how often the card changes (SHOULD). - Send an
ETagderived fromversionor from a hash of the card (SHOULD).Last-Modifiedis optional.
Check what your server sends:
curl -sI https://<your-domain>/.well-known/agent-card.json | grep -i -E 'cache-control|etag|last-modified'Clients are expected to follow HTTP caching (RFC 9111) and, once a cached card expires, send a conditional request with If-None-Match or If-Modified-Since. Without an ETag or a Last-Modified there is nothing to be conditional on, so they must download the whole card again.
8. When it breaks
Most card problems show up as an error from the agent or a client that cannot proceed. This table maps each symptom to the card mistake behind it.
| What you see | Likely card cause | Fix |
|---|---|---|
UnsupportedOperationError from SendStreamingMessage or SubscribeToTask | capabilities.streaming is false or absent | Declare true once streaming works; otherwise clients have to use the non-streaming SendMessage |
PushNotificationNotSupportedError from any push config operation | capabilities.pushNotifications is false or absent | Declare true once all four operations work |
UnsupportedOperationError from GetExtendedAgentCard | capabilities.extendedAgentCard is false or absent | Declare true and configure an extended card |
ExtendedAgentCardNotConfiguredError | extendedAgentCard: true, but no extended card is configured | Configure the extended card, or declare false |
ExtensionSupportRequiredError | An extension is required: true and the client did not declare it in A2A-Extensions | Have the client declare it, or set required: false if your agent can work without it |
| Client cannot choose an endpoint, or calls the wrong one | Only a v0.3 url, an empty supportedInterfaces, or entries in the wrong order | Run the validator; list interfaces in preference order |
| Authentication fails with correct credentials | A requirement names a scheme that is not in securitySchemes, or is written in the v0.3 shape | Run the validator; use {"schemes": {"name": {"list": []}}} |
| Signature verification fails | Content changed after signing, signatures was not excluded, presence of optional fields differs, or the serializer is not RFC 8785 compliant | Diff the canonical forms with the jq command above, and sign with a JCS library |
| Clients keep downloading the full card | No Cache-Control or ETag | Add both (section 8.6.1) |
Required fields at a glance
These are the fields a2a.proto at v1.0.1 marks REQUIRED. The validator checks exactly these. The tables below are compared with the proto by a script, not by eye.
| Object | Required fields |
|---|---|
AgentCard | name, description, version, supportedInterfaces, capabilities, defaultInputModes, defaultOutputModes, skills |
AgentInterface | url, protocolBinding, protocolVersion |
AgentProvider | url, organization |
AgentSkill | id, name, description, tags |
AgentCardSignature | protected, signature |
apiKeySecurityScheme | location, name |
httpAuthSecurityScheme | scheme |
oauth2SecurityScheme | flows |
openIdConnectSecurityScheme | openIdConnectUrl |
authorizationCode flow | authorizationUrl, tokenUrl, scopes |
clientCredentials flow | tokenUrl, scopes |
deviceCode flow | deviceAuthorizationUrl, tokenUrl, scopes |
AgentCapabilities, AgentExtension, mtlsSecurityScheme, and the deprecated implicit and password flows have no required fields.
Related pages
- Agent Card , AgentSkill , and SecurityScheme : the objects this guide fills in.
- Agent Discovery : how clients find and trust cards.
- A2A Error Codes : the error names used in the tables above.
- What’s New in A2A Protocol v1.0 : the full v0.3 to v1.0 migration guide.
Source: A2A official specification: Capability Validation (3.3.4), Field Presence and Optionality (5.7), Protocol Binding identification (5.8), Discovery Mechanisms, Protocol Declaration Requirements, Agent Card Signing, Sample Agent Card, and Caching (8.2 to 8.6)
; a2a.proto at v1.0.1
; sample cards from the specification at the v1.0.1 tag and at main commit 98279d6