Download OpenAPI specification:
A public registry of signed A2A Agent Cards, where the key is the account.
Anyone may publish an Agent Card; the card is signed by its owner's key; only a holder of an authorized key can change it. There are no accounts, no passwords and no sessions, so no endpoint here takes a credential — a write is authorized by a signature over the operation, carried in the request body.
An entry may also certify ownership of domains, each proved by a record in that domain's own DNS zone.
Two documents are normative and this one is not:
SPEC.md
— identity model, card processing, writes, public endpoints, limits, and
the problem table.DOMAIN-CERTIFICATION.md
— the domain certification profile, additive to the above.SPEC.md is normative and this document is not. Where the two disagree, SPEC.md wins and this document is the defect.
It is written by hand rather than derived from the code, for the same reason the field-presence table of SPEC.md §5.2 is: a description generated from an implementation documents that implementation, including its mistakes, and stops being a second opinion about what the protocol says. The cost of writing it by hand is drift, so drift is what the test guards — crates/registry-api/tests/openapi.rs fails when the operations here stop matching the router, or when the problem codes stop matching the catalogue, SPEC.md §9 or the published problem pages.
JSON rather than YAML because this file is served verbatim at /v1/openapi.json: there is no build step between what is reviewed here and what a client fetches, and the test reads it with the JSON parser the service already depends on.
Anonymous and read-only. Served from the object store behind the CDN, never
from compute — which is why a withdrawn entry answers 404 here and the
record (§7.3) is where its status is stated.
Cursor-paginated. There is no search, no ranking and no scoring, and entries carry no badge of any kind.
Ties on updatedAt are broken by agentId, so the order is total and a
cursor can neither skip nor repeat an entry. A client ends a listing on
an empty page, not on a missing nextCursor: the registry may return a
cursor whenever the page filled, without knowing whether anything follows.
| limit | integer [ 1 .. 100 ] Default: 25 Page size. Values outside the range are clamped, not refused. |
| cursor | string Opaque, and belonging to the registry that issued it. One this registry did
not issue — or one naming an entry it no longer holds — is
|
{- "agents": [
- {
- "agentId": "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY",
- "status": "ACTIVE",
- "seq": 1,
- "cardDigest": "string",
- "cardVersion": "string",
- "authorizedKids": [
- "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY"
], - "createdAt": "2026-09-01T08:44:46.283Z",
- "updatedAt": "2026-09-01T08:44:46.283Z",
- "domains": [
- {
- "domain": "acme.com",
- "certifiedAt": "2026-09-01T08:44:46.283Z",
- "lastCheckedAt": "2026-09-01T08:44:46.283Z"
}
]
}
], - "nextCursor": "string"
}A projection: status, current digest, the authorized key set, timestamps, certified domains and same-origin links. It is a convenience, never a substitute for verifying the card.
This is also the only endpoint that distinguishes a withdrawn entry from
one that never existed: it answers 200 with status: WITHDRAWN, where
the card and key-set paths answer 404 for both.
| agentId required | string (AgentId) ^[A-Za-z0-9_-]{43}$ Examples: NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY The RFC 7638 thumbprint of the genesis key — 43 base64url characters, computable offline before anything is published. |
{- "agentId": "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY",
- "status": "ACTIVE",
- "seq": 1,
- "cardDigest": "string",
- "cardVersion": "string",
- "authorizedKids": [
- "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY"
], - "createdAt": "2026-09-01T08:44:46.283Z",
- "updatedAt": "2026-09-01T08:44:46.283Z",
- "domains": [
- {
- "domain": "acme.com",
- "certifiedAt": "2026-09-01T08:44:46.283Z",
- "lastCheckedAt": "2026-09-01T08:44:46.283Z"
}
]
}The body is cardBytes verbatim. The registry never re-serializes a stored
card on read, because a re-serialized card is a different document and its
signatures no longer verify.
ETag is opaque, in the sense of RFC 9110, and a client must not parse
it as a digest. The API origin happens to derive it from the card digest;
the CDN origin serves whatever validator the object store computed — two
origins, two shapes, one endpoint. A client that needs the digest computes
it from the bytes it received, which is not a workaround but the only
correct behaviour: a digest handed over by the same server whose answer it
is meant to check establishes nothing.
On 404 and 410. The API origin distinguishes a withdrawn entry from
an absent one; the CDN origin, which serves objects the reconciler deletes
on withdrawal, has nothing left to answer 410 from. Consumers MUST treat
404 from this path as "no current card" without inferring which of the
two it means, and read the record (§7.3) when the difference matters.
| agentId required | string (AgentId) ^[A-Za-z0-9_-]{43}$ Examples: NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY The RFC 7638 thumbprint of the genesis key — 43 base64url characters, computable offline before anything is published. |
| If-None-Match | string |
{- "name": "string",
- "description": "string",
- "version": "string",
- "signatures": [
- {
- "protected": "string",
- "payload": "string",
- "signature": "string"
}
]
}Each key carries its kid — the RFC 7638 thumbprint of that key, matching
the kid in the protected header of any signature it produced.
The registry rebuilds each entry from the key material it verified rather
than republishing the JWK as submitted, so a submitter cannot place a
kid, an alg or any other member of their choosing into the published
set. The only members served are kty, crv, the coordinates, kid and
use: "sig".
A withdrawn entry serves nothing here, with the same 404 shape as the
card path.
| agentId required | string (AgentId) ^[A-Za-z0-9_-]{43}$ Examples: NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY The RFC 7638 thumbprint of the genesis key — 43 base64url characters, computable offline before anything is published. |
{- "keys": [
- {
- "kty": "EC",
- "crv": "P-256",
- "x": "string",
- "y": "string",
- "n": "string",
- "e": "string",
- "kid": "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY",
- "use": "sig"
}
]
}| agentId required | string (AgentId) ^[A-Za-z0-9_-]{43}$ Examples: NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY The RFC 7638 thumbprint of the genesis key — 43 base64url characters, computable offline before anything is published. |
| limit | integer [ 1 .. 100 ] Default: 25 Page size. Values outside the range are clamped, not refused. |
| cursor | string Opaque, and belonging to the registry that issued it. One this registry did
not issue — or one naming an entry it no longer holds — is
|
{- "versions": [
- {
- "seq": 1,
- "cardDigest": "string",
- "cardVersion": "string",
- "signingKids": [
- "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY"
], - "createdAt": "2026-09-01T08:44:46.283Z",
}
], - "nextCursor": "string"
}Immutable exact bytes. The digest is in the request path, so it never needs carrying in a header, and these responses are cached for a year.
One version has one URL. {cardDigest} must be the exact form the registry
publishes — sha256: then 64 lowercase hex characters — and any other
spelling, the bare hex included, is 404: a second spelling would be a
second permanently cached entry for the same bytes, with its own ETag.
| agentId required | string (AgentId) ^[A-Za-z0-9_-]{43}$ Examples: NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY The RFC 7638 thumbprint of the genesis key — 43 base64url characters, computable offline before anything is published. |
| cardDigest required | string (Digest) ^sha256:[0-9a-f]{64}$ Exactly as the registry publishes it. Any other spelling is |
{- "name": "string",
- "description": "string",
- "version": "string",
- "signatures": [
- {
- "protected": "string",
- "payload": "string",
- "signature": "string"
}
]
}Rare, and authorized by a signature over the operation rather than by a
credential. Each carries its own signed payload; none accepts an
Authorization header, because there is nothing to put in one.
agentId is client-derived — the RFC 7638 thumbprint of the genesis key —
so the address is known before anything is published and this operation
needs no server-allocated identifier.
The body carries the card, the public keys that signed it, and one
publication proof per key entering the authorized set: the set of proof
signers must equal the set of card signers. A key that signed the card but
produced no proof is UNPROVEN_KEY.
That requirement is not bookkeeping. A card's signing payload is public the moment the card is published, so anyone can append a signature to someone else's card without invalidating the original; were one proof enough, an attacker could open an entry in their own name whose published key set names a holder who never asked for it.
Idempotent by construction. Submitting bytes identical to the current
version answers 200 and changes nothing, including the sequence — which
is exactly what a client retrying after a network timeout does, and its
earlier write may well have succeeded. Different bytes at the same version
are still refused by §6.4.
| agentId required | string (AgentId) ^[A-Za-z0-9_-]{43}$ Examples: NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY The RFC 7638 thumbprint of the genesis key — 43 base64url characters, computable offline before anything is published. |
| If-Match | string Example: "sha256:0dc224884a76d8bcbf841c01c501d5e94b1abba0ada08d93bd966db897100709" The digest of the version this write expects to replace. Comparison is
strong, the opposite of the weak comparison |
required | object (SignedAgentCard) A complete A2A AgentCard including its |
required | Array of objects (Jwk) [ 1 .. 8 ] items The public keys that signed the card. Every proof signer must be among them, and no key may appear twice. |
required | Array of objects (PublicationProof) [ 1 .. 8 ] items One per key entering the authorized set, each a JWS over a
|
{- "agentCard": {
- "name": "string",
- "description": "string",
- "version": "string",
- "signatures": [
- {
- "protected": "string",
- "payload": "string",
- "signature": "string"
}
]
}, - "keys": [
- {
- "kty": "EC",
- "crv": "P-256",
- "x": "string",
- "y": "string",
- "n": "string",
- "e": "string",
- "kid": "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY",
- "use": "sig"
}
], - "proofs": [
- {
- "payload": "string",
- "protected": "string",
- "signature": "string"
}
]
}{- "agentId": "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY",
- "status": "ACTIVE",
- "seq": 1,
- "cardDigest": "string",
- "cardVersion": "string",
- "authorizedKids": [
- "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY"
], - "createdAt": "2026-09-01T08:44:46.283Z",
- "updatedAt": "2026-09-01T08:44:46.283Z",
- "domains": [
- {
- "domain": "acme.com",
- "certifiedAt": "2026-09-01T08:44:46.283Z",
- "lastCheckedAt": "2026-09-01T08:44:46.283Z"
}
]
}In V1 because the registry never holds the publisher's key: the publisher must be able to remove their own entry without asking an operator.
The signed payload names the current cardDigest, which is what prevents
an older withdrawal being replayed.
The entry becomes WITHDRAWN, and the registry stops serving its current
card and its JWKS. Published versions remain readable at their digest —
what was published is not erased, only its status changes. The agentId
is never reusable.
| agentId required | string (AgentId) ^[A-Za-z0-9_-]{43}$ Examples: NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY The RFC 7638 thumbprint of the genesis key — 43 base64url characters, computable offline before anything is published. |
required | object (Withdrawal) A JWS whose payload asks this registry to withdraw this entry. |
required | Array of objects (Jwk) non-empty |
{- "withdrawal": {
- "payload": "string",
- "protected": "string",
- "signature": "string"
}, - "keys": [
- {
- "kty": "EC",
- "crv": "P-256",
- "x": "string",
- "y": "string",
- "n": "string",
- "e": "string",
- "kid": "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY",
- "use": "sig"
}
]
}{- "agentId": "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY",
- "status": "ACTIVE",
- "seq": 1,
- "cardDigest": "string",
- "cardVersion": "string",
- "authorizedKids": [
- "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY"
], - "createdAt": "2026-09-01T08:44:46.283Z",
- "updatedAt": "2026-09-01T08:44:46.283Z",
- "domains": [
- {
- "domain": "acme.com",
- "certifiedAt": "2026-09-01T08:44:46.283Z",
- "lastCheckedAt": "2026-09-01T08:44:46.283Z"
}
]
}Two assertions, each made where that side actually has authority: the
domain speaks in its own DNS zone, and the key speaks by signing. Neither
half alone is accepted — a zone has no authority over someone's key, and a
signed request naming example.com establishes nothing about
example.com.
The signed domains array is the complete set: it replaces whatever
was there, and an empty array removes every certification. There is no
separate deletion operation, for the same reason there is no rotation
operation — the request states the whole set, and stating a smaller one is
how you shrink it.
The payload carries no cardDigest, unlike a withdrawal: a certification
is a statement about domains, not about a card, and binding it to a digest
would expire every certification at the next publish for no gain.
Only what the registry can currently observe is published. It re-resolves every requested domain hourly; a domain missing for three consecutive passes stops being listed and returns by itself when its record does.
| agentId required | string (AgentId) ^[A-Za-z0-9_-]{43}$ Examples: NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY The RFC 7638 thumbprint of the genesis key — 43 base64url characters, computable offline before anything is published. |
required | object (Certification) A JWS whose payload states the complete set of domains to certify. |
required | Array of objects (Jwk) non-empty |
{- "certification": {
- "payload": "string",
- "protected": "string",
- "signature": "string"
}, - "keys": [
- {
- "kty": "EC",
- "crv": "P-256",
- "x": "string",
- "y": "string",
- "n": "string",
- "e": "string",
- "kid": "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY",
- "use": "sig"
}
]
}{- "agentId": "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY",
- "status": "ACTIVE",
- "seq": 1,
- "cardDigest": "string",
- "cardVersion": "string",
- "authorizedKids": [
- "NTkoUedrnrVKX8n7EZWHpqbeI6klwPejKqcGvirtkgY"
], - "createdAt": "2026-09-01T08:44:46.283Z",
- "updatedAt": "2026-09-01T08:44:46.283Z",
- "domains": [
- {
- "domain": "acme.com",
- "certifiedAt": "2026-09-01T08:44:46.283Z",
- "lastCheckedAt": "2026-09-01T08:44:46.283Z"
}
]
}The canonical origin, the pinned A2A commit, accepted alg values, the
limits of §8, the domain certification parameters, and a digest of the
field-presence table of §5.2.
The presence-table digest is the load-bearing member. A2A pins a protocol commit but publishes no table derived from it, so two implementations can read the same commit and still disagree about one field — which yields two canonical documents and two signatures over what looks like the same card. The digest turns "we pinned the same commit" into something a second implementation can check.
{- "claim": "string",
- "a2a": {
- "version": "string",
- "commit": "string"
}, - "canonicalization": "RFC 8785",
- "keyIdentifiers": "RFC 7638",
- "signatures": {
- "format": "RFC 7515",
- "algorithms": [
- "ES256"
]
}, - "limits": {
- "requestBodyBytes": 0,
- "cardBytes": 0,
- "keysPerRequest": 0,
- "signaturesPerCard": 0,
- "validationIssuesReported": 0
}, - "presenceTable": {
- "derivedFrom": "string",
- "digest": "string"
}, - "domainCertification": {
- "version": "A2A1",
- "record": "_a2a",
- "maxDomains": 0,
- "revalidateIntervalSeconds": 0,
- "removalAfterFailedPasses": 0,
- "profile": "string"
},
}