How agents find and trust each other: A2A agent cards and ERC-8004 identity
Build a one-skill agent that publishes its own A2A agent card, read a real agent's identity from an ERC-8004 registry, and write a checker that follows one to the other. Every command here was run, and every output is real.
Picture an agent that books a shared taxi for five colleagues. It can find the ride, but splitting the fare is another agent's job, run by another team on another server. Before your agent sends that agent a single message, it needs two answers. Where is it, and what can it actually do? And why should your agent believe what it says?
The first question is discovery. The second is trust. Below, I build a small answer to each with code that runs: an A2A agent that serves its own agent card, a read of a real agent's on-chain identity under ERC-8004, and a checker that walks from one to the other.
The idea in plain words
A2A (Agent2Agent) is an open protocol that lets agents talk to each other across companies and frameworks. Since August 2026 it has been a project of the Agentic AI Foundation, the Linux Foundation home it shares with MCP, goose and AGENTS.md. Discovery in A2A starts with the agent card: a small JSON file that works like a business card. It gives the agent's name, its skills, the URL to call and how to authenticate. Servers publish it at a well-known path, a fixed address every client knows to check: /.well-known/agent-card.json. Older tutorials use agent.json; the spec renamed it in version 0.3.0.
A card is self-description, though. Anyone can publish one that claims to be the best bill splitter in Lagos. ERC-8004, a draft Ethereum standard called Trustless Agents, adds three public registries on a blockchain:
- the Identity Registry gives each agent an ID as an NFT (a token with exactly one owner), and the NFT points to a registration file listing the agent's endpoints, including its A2A card;
- the Reputation Registry lets clients who used the agent post a score, with optional tags and a link to details;
- the Validation Registry lets an agent ask an independent checker to verify a piece of work, and records the verdict from 0 to 100.
Together, the card says what an agent claims it can do, and the registries say who controls it, what other clients reported and what checkers found. That matters in an agentic economy, where agents act and pay for people and must pick strangers they can rely on.
Before you start
- Python 3.11 or newer. The A2A SDK needs 3.10 or newer; I used 3.11.
- Node.js 20 or newer. I used Node 24.18.0.
- curl, to fetch the card by hand.
- No wallet, no keys, no tokens. Part 2 only reads from a public RPC endpoint. The optional testnet step at the end is the one place a key appears, and it stays in
.env. - Pinned versions:
a2a-sdk[http-server]1.2.1 (A2A spec 1.0),uvicorn0.54.0 andviem2.57.3.
On Windows, keep the project folder path short, such as C:\dev\bill-splitter. When I installed into a deeply nested folder, pip failed on the long file paths inside the cryptography package.
Part 1: build an A2A agent with one skill
Step 1. Set up the project
mkdir bill-splitter
cd bill-splitter
python -m venv .venv # use python3 on macOS and Linux
# Windows: .venv\Scripts\activate
# macOS / Linux: source .venv/bin/activate
pip install "a2a-sdk[http-server]==1.2.1" "uvicorn==0.54.0"
Step 2. Write the agent
# agent.py: a minimal A2A agent with one skill, served over JSON-RPC
import re
import uuid
import uvicorn
from starlette.applications import Starlette
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import (
AgentCapabilities,
AgentCard,
AgentInterface,
AgentSkill,
Message,
Part,
Role,
)
HOST, PORT = "127.0.0.1", 9999
BASE_URL = f"http://{HOST}:{PORT}"
def split_bill(text: str) -> str:
"""The one skill: split a bill evenly, in whole naira, and say who pays the remainder."""
numbers = [int(n) for n in re.findall(r"\d+", text.replace(",", ""))]
if len(numbers) < 2 or numbers[1] == 0:
return "Tell me a total and a number of people, e.g. 'split 12500 between 4'."
total, people = numbers[0], numbers[1]
share, remainder = divmod(total, people)
if remainder == 0:
return f"{total} split {people} ways is {share} each."
return (f"{total} split {people} ways is {share} each, "
f"and one person adds the extra {remainder}.")
class SplitBillExecutor(AgentExecutor):
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
reply = Message(
role=Role.ROLE_AGENT,
message_id=str(uuid.uuid4()),
context_id=context.context_id,
parts=[Part(text=split_bill(context.get_user_input()))],
)
await event_queue.enqueue_event(reply)
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
raise NotImplementedError("Replies are instant; nothing to cancel.")
agent_card = AgentCard(
name="Bill Splitter",
description="Splits a shared bill evenly between a group of people.",
version="0.1.0",
capabilities=AgentCapabilities(streaming=False, push_notifications=False),
default_input_modes=["text/plain"],
default_output_modes=["text/plain"],
supported_interfaces=[
AgentInterface(
protocol_binding="JSONRPC",
protocol_version="1.0",
url=f"{BASE_URL}/a2a/jsonrpc",
)
],
skills=[
AgentSkill(
id="split_bill",
name="Split a bill",
description="Given a total and a number of people, returns each share.",
tags=["payments", "groups"],
examples=["split 12500 between 4"],
)
],
)
handler = DefaultRequestHandler(
agent_executor=SplitBillExecutor(),
task_store=InMemoryTaskStore(),
agent_card=agent_card,
)
app = Starlette(
routes=create_agent_card_routes(agent_card)
+ create_jsonrpc_routes(handler, rpc_url="/a2a/jsonrpc")
)
if __name__ == "__main__":
uvicorn.run(app, host=HOST, port=PORT)
Three parts matter. split_bill is the skill itself: plain Python, no model. A real agent would call a model here, and the protocol wouldn't care. The executor is A2A's adapter: it receives a message and puts a reply on an event queue. The AgentCard is the advert.
In the 1.0 spec a card must have a name, description, version, capabilities, default input and output modes, skills and supportedInterfaces: the URLs and protocol bindings (JSON-RPC, gRPC or HTTP+JSON) the agent accepts, in order of preference. Each skill needs an id, name, description and tags. Examples are optional, but they help both people and models pick the right skill. create_agent_card_routes serves the card at the well-known path by default.
Step 3. Write a client
# client.py: discover the agent from its card, then call its skill
import asyncio
import sys
import uuid
import httpx
from a2a.client import A2ACardResolver, create_client
from a2a.helpers import get_message_text
from a2a.types import Message, Part, Role, SendMessageRequest
BASE_URL = "http://127.0.0.1:9999"
async def main(text: str) -> None:
async with httpx.AsyncClient() as http:
card = await A2ACardResolver(http, BASE_URL).get_agent_card()
print(f"Found agent: {card.name} v{card.version}")
for skill in card.skills:
print(f" skill {skill.id}: {skill.description}")
print(f" endpoint: {card.supported_interfaces[0].url}")
client = await create_client(card)
request = SendMessageRequest(
message=Message(
role=Role.ROLE_USER,
message_id=str(uuid.uuid4()),
parts=[Part(text=text)],
)
)
async for event in client.send_message(request):
if event.HasField("message"):
print("Agent:", get_message_text(event.message))
await client.close()
if __name__ == "__main__":
asyncio.run(main(" ".join(sys.argv[1:]) or "split 12500 between 4"))
The client never hard-codes the endpoint. A2ACardResolver fetches the card from the well-known path, and create_client uses the first interface in supportedInterfaces that it supports.
Run it and see it work
In the first terminal, start the agent:
python agent.py
INFO: Started server process [31264]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:9999 (Press CTRL+C to quit)
In a second terminal (activate the virtual environment again), fetch the card:
curl -s http://127.0.0.1:9999/.well-known/agent-card.json | python -m json.tool
{
"name": "Bill Splitter",
"description": "Splits a shared bill evenly between a group of people.",
"supportedInterfaces": [
{
"url": "http://127.0.0.1:9999/a2a/jsonrpc",
"protocolBinding": "JSONRPC",
"protocolVersion": "1.0"
}
],
"version": "0.1.0",
"capabilities": {
"streaming": false,
"pushNotifications": false
},
"defaultInputModes": [
"text/plain"
],
"defaultOutputModes": [
"text/plain"
],
"skills": [
{
"id": "split_bill",
"name": "Split a bill",
"description": "Given a total and a number of people, returns each share.",
"tags": [
"payments",
"groups"
],
"examples": [
"split 12500 between 4"
]
}
]
}
Then call the skill through the official client:
python client.py
python client.py "split 10000 between 3"
Found agent: Bill Splitter v0.1.0
skill split_bill: Given a total and a number of people, returns each share.
endpoint: http://127.0.0.1:9999/a2a/jsonrpc
Agent: 12500 split 4 ways is 3125 each.
Found agent: Bill Splitter v0.1.0
skill split_bill: Given a total and a number of people, returns each share.
endpoint: http://127.0.0.1:9999/a2a/jsonrpc
Agent: 10000 split 3 ways is 3333 each, and one person adds the extra 1.
Under the client is plain JSON-RPC. In bash or Git Bash you can make the same call by hand:
curl -s -X POST http://127.0.0.1:9999/a2a/jsonrpc \
-H "Content-Type: application/json" -H "A2A-Version: 1.0" \
-d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{"message":{"role":"ROLE_USER","messageId":"m-1","parts":[{"text":"split 9000 between 3"}]}}}' \
| python -m json.tool
{
"result": {
"message": {
"messageId": "d6585946-721a-4f61-b9c1-b21f79d6fdc6",
"contextId": "4e4cb324-7b0b-4d8c-8e76-10c58c724a05",
"role": "ROLE_AGENT",
"parts": [
{
"text": "9000 split 3 ways is 3000 each."
}
]
}
},
"id": 1,
"jsonrpc": "2.0"
}
The A2A-Version header tells the server which protocol version you speak. The spec says clients must send it, and a server that gets no header assumes 0.3. Your IDs will differ from mine.
Part 2: read a real agent's identity on-chain
Here are the three ERC-8004 registries side by side:
| Registry | What it stores | Who writes | Main reads |
|---|---|---|---|
| Identity | One NFT per agent: owner, agent URI, optional metadata, a verified payment wallet | The agent's owner or operators | tokenURI, ownerOf, getAgentWallet |
| Reputation | A signed score with its decimals, two optional tags, an optional link and hash to a feedback file | Any client except the agent's owner or operators | getSummary, readAllFeedback, getClients |
| Validation | Requests for checks, and validator responses from 0 to 100 | The owner or an operator requests; only the named validator responds | getValidationStatus, getSummary |
The registries are meant to be one singleton per chain. The official erc-8004-contracts repository lists the mainnet Identity Registry at 0x8004A169FB4a3325136EB29fA0ceB6D2e539a432 on Ethereum, Base, Arbitrum, Celo and many other chains, and the testnet one at 0x8004A818BFB912233c491871b3d84c89A494BD9e. Its deployment list gives addresses only for Identity and Reputation. The repository warns that the Validation Registry is still under revision with the TEE community.
Step 4. Set up a Node project
mkdir agent-identity
cd agent-identity
npm init -y
npm pkg set type=module
npm i viem@2.57.3 --save-exact
Step 5. Read an agent from the Identity Registry
For the example I read Celo mainnet, because its public RPC at forno.celo.org needs no key, and agent 9765, Omni402, one of my own x402 experiments (you can see it on 8004scan). To read another chain, swap the chain import and the RPC URL; on the chains in the deployment list, the address stays the same.
// read-agent.mjs: read an agent's ERC-8004 identity from Celo mainnet (read-only, no keys)
import { createPublicClient, http, parseAbi } from "viem";
import { celo } from "viem/chains";
// Celo mainnet addresses, from github.com/erc-8004/erc-8004-contracts
const IDENTITY_REGISTRY = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432";
const REPUTATION_REGISTRY = "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63";
const AGENT_ID = BigInt(process.argv[2] ?? "9765");
const identityAbi = parseAbi([
"function name() view returns (string)",
"function getVersion() view returns (string)",
"function ownerOf(uint256 tokenId) view returns (address)",
"function tokenURI(uint256 tokenId) view returns (string)",
"function getAgentWallet(uint256 agentId) view returns (address)",
]);
const reputationAbi = parseAbi(["function getClients(uint256 agentId) view returns (address[])"]);
const client = createPublicClient({ chain: celo, transport: http("https://forno.celo.org") });
const read = (functionName, args = []) =>
client.readContract({ address: IDENTITY_REGISTRY, abi: identityAbi, functionName, args });
// The agentURI can be https://, ipfs:// or an inline data: URI
async function fetchRegistration(uri) {
if (uri.startsWith("data:")) {
const [meta, body] = uri.split(",", 2);
const text = meta.endsWith(";base64")
? Buffer.from(body, "base64").toString("utf8")
: decodeURIComponent(body);
return JSON.parse(text);
}
const url = uri.startsWith("ipfs://") ? `https://ipfs.io/ipfs/${uri.slice(7)}` : uri;
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) throw new Error(`GET ${url} returned ${res.status}`);
return res.json();
}
const [registry, version, owner, agentURI, wallet, raters] = await Promise.all([
read("name"),
read("getVersion"),
read("ownerOf", [AGENT_ID]),
read("tokenURI", [AGENT_ID]),
read("getAgentWallet", [AGENT_ID]),
client.readContract({
address: REPUTATION_REGISTRY, abi: reputationAbi, functionName: "getClients", args: [AGENT_ID],
}),
]);
console.log(`registry : ${registry} v${version} on chain ${celo.id}`);
console.log(`agentId : ${AGENT_ID}`);
console.log(`owner : ${owner}`);
console.log(`wallet : ${wallet}`);
console.log(`agentURI : ${agentURI}`);
console.log(`feedback : ${raters.length} client address(es) have rated this agent`);
console.log("\nregistration file:");
// Long strings are shortened so the output stays readable
const short = (k, v) => (typeof v === "string" && v.length > 80 ? v.slice(0, 77) + "..." : v);
console.log(JSON.stringify(await fetchRegistration(agentURI), short, 2));
node read-agent.mjs
registry : AgentIdentity v2.0.0 on chain 42220
agentId : 9765
owner : 0x20ECAe56e1c21a0d4079bDD0202D0fb6d1FD5000
wallet : 0x20ECAe56e1c21a0d4079bDD0202D0fb6d1FD5000
agentURI : https://raw.githubusercontent.com/eddiemessiah/omni402/main/agent.json
feedback : 0 client address(es) have rated this agent
registration file:
{
"type": "Agent",
"name": "Omni402 Agent",
"description": "Autonomous agent that turns any HTTP API into a pay-per-call x402/MPP endpoin...",
"endpoints": [
{
"type": "mcp",
"url": "https://github.com/eddiemessiah/omni402"
},
{
"type": "wallet",
"address": "0x20ECAe56e1c21a0d4079bDD0202D0fb6d1FD5000",
"chainId": 42220
}
],
"supportedTrust": [
"reputation"
]
}
owner holds the NFT, so whoever controls that key controls the identity. wallet is the reserved agentWallet, the address where the agent wants to be paid. It starts as the owner's address, can only change with a signature from the new wallet, and is wiped when the NFT changes hands. agentURI is just a URL, here a file on GitHub. No client has left feedback yet.
And the registration file doesn't match the current spec. The EIP asks for type set to the registration-v1 URL, a services list of name and endpoint pairs, and a registrations list that points back to the on-chain ID. This file has endpoints, a plain "Agent" type and no back-link. It's my own file, and a useful reminder: clients will meet many files like it, so validate before you trust.
Link the two: point the registration file at the card
Step 6. Write a registration file for the bill splitter
{
"type": "https://eips.ethereum.org/EIPS/eip-8004#registration-v1",
"name": "Bill Splitter",
"description": "Splits a shared bill evenly between a group of people. Free, text in and text out.",
"image": "https://example.com/bill-splitter.png",
"services": [
{
"name": "A2A",
"endpoint": "http://127.0.0.1:9999/.well-known/agent-card.json",
"version": "1.0"
}
],
"x402Support": false,
"active": true,
"registrations": [],
"supportedTrust": ["reputation"]
}
The services entry named A2A holds the card URL. registrations stays empty until you mint an ID. The EIP says agents should have at least one, so the checker below warns rather than fails.
Step 7. Write a checker that follows the link
// check-registration.mjs: validate an ERC-8004 registration file and the A2A card it points to
// Usage: node check-registration.mjs registration.json
// node check-registration.mjs --agent 9765 (reads the agentURI from Celo mainnet)
import { readFile } from "node:fs/promises";
import { createPublicClient, http, parseAbi } from "viem";
import { celo } from "viem/chains";
const IDENTITY_REGISTRY = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432";
const REG_TYPE = "https://eips.ethereum.org/EIPS/eip-8004#registration-v1";
let failures = 0;
const pass = (msg) => console.log(` PASS ${msg}`);
const warn = (msg) => console.log(` WARN ${msg}`);
const fail = (msg) => { failures++; console.log(` FAIL ${msg}`); };
const check = (ok, msg, level = fail) => (ok ? pass(msg) : level(msg));
const nonEmpty = (v) => (Array.isArray(v) ? v.length > 0 : typeof v === "string" && v.trim() !== "");
async function fetchJson(uri) {
if (uri.startsWith("data:")) {
const [meta, body] = uri.split(",", 2);
return JSON.parse(meta.endsWith(";base64") ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body));
}
const url = uri.startsWith("ipfs://") ? `https://ipfs.io/ipfs/${uri.slice(7)}` : uri;
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) throw new Error(`GET ${url} returned ${res.status}`);
return res.json();
}
async function loadRegistration(args) {
if (args[0] !== "--agent") return { file: JSON.parse(await readFile(args[0], "utf8")), agentId: null };
const agentId = BigInt(args[1]);
const client = createPublicClient({ chain: celo, transport: http("https://forno.celo.org") });
const uri = await client.readContract({
address: IDENTITY_REGISTRY,
abi: parseAbi(["function tokenURI(uint256) view returns (string)"]),
functionName: "tokenURI",
args: [agentId],
});
console.log(`agentURI for #${agentId}: ${uri}`);
return { file: await fetchJson(uri), agentId };
}
function checkRegistration(reg, agentId) {
console.log("\nERC-8004 registration file");
check(reg.type === REG_TYPE, `type is ${REG_TYPE}`);
check(nonEmpty(reg.name), "name is set");
check(nonEmpty(reg.description), "description is set");
check(nonEmpty(reg.image), "image is set (SHOULD, for NFT apps)", warn);
check(Array.isArray(reg.services) && reg.services.length > 0, "services lists at least one endpoint");
const regs = Array.isArray(reg.registrations) ? reg.registrations : [];
check(regs.length > 0, "registrations links back to an on-chain identity (SHOULD)", warn);
for (const r of regs) {
check(/^eip155:\d+:0x[0-9a-fA-F]{40}$/.test(r.agentRegistry ?? "") && r.agentId !== undefined,
`registration ${r.agentRegistry} #${r.agentId} is well formed`);
}
if (agentId !== null) {
const want = `eip155:${celo.id}:${IDENTITY_REGISTRY}`.toLowerCase();
check(regs.some((r) => String(r.agentId) === String(agentId) && r.agentRegistry?.toLowerCase() === want),
`file names this exact on-chain agent (#${agentId})`);
}
return (reg.services ?? []).find((s) => s.name === "A2A");
}
function checkAgentCard(card) {
console.log("\nA2A agent card");
for (const field of ["name", "description", "version"]) check(nonEmpty(card[field]), `${field} is set`);
check(card.capabilities && typeof card.capabilities === "object", "capabilities is an object");
check(nonEmpty(card.defaultInputModes) && nonEmpty(card.defaultOutputModes), "default input and output modes are set");
const ifaces = card.supportedInterfaces ?? [];
check(ifaces.length > 0 && ifaces.every((i) => nonEmpty(i.url) && nonEmpty(i.protocolBinding)),
"supportedInterfaces lists at least one url + protocolBinding");
for (const i of ifaces) check(i.url.startsWith("https://"), `interface ${i.url} uses HTTPS (MUST in production)`, warn);
const skills = card.skills ?? [];
check(skills.length > 0, "at least one skill");
for (const s of skills) {
check(nonEmpty(s.id) && nonEmpty(s.name) && nonEmpty(s.description) && Array.isArray(s.tags),
`skill "${s.id}" has id, name, description and tags`);
}
}
const { file, agentId } = await loadRegistration(process.argv.slice(2));
const a2a = checkRegistration(file, agentId);
if (!a2a) {
fail("no service named A2A, so there is no agent card to follow");
} else {
check(a2a.endpoint.endsWith("/.well-known/agent-card.json"), "A2A endpoint uses the well-known card path", warn);
try {
const card = await fetchJson(a2a.endpoint);
checkAgentCard(card);
check(card.name === file.name, "card name matches registration name", warn);
} catch (err) {
fail(`could not fetch agent card: ${err.message}`);
}
}
console.log(`\n${failures === 0 ? "OK" : `${failures} failure(s)`}`);
process.exitCode = failures === 0 ? 0 : 1;
With the agent from Part 1 still running, check the local file, then agent 9765:
node check-registration.mjs registration.json
node check-registration.mjs --agent 9765
ERC-8004 registration file
PASS type is https://eips.ethereum.org/EIPS/eip-8004#registration-v1
PASS name is set
PASS description is set
PASS image is set (SHOULD, for NFT apps)
PASS services lists at least one endpoint
WARN registrations links back to an on-chain identity (SHOULD)
PASS A2A endpoint uses the well-known card path
A2A agent card
PASS name is set
PASS description is set
PASS version is set
PASS capabilities is an object
PASS default input and output modes are set
PASS supportedInterfaces lists at least one url + protocolBinding
WARN interface http://127.0.0.1:9999/a2a/jsonrpc uses HTTPS (MUST in production)
PASS at least one skill
PASS skill "split_bill" has id, name, description and tags
PASS card name matches registration name
OK
agentURI for #9765: https://raw.githubusercontent.com/eddiemessiah/omni402/main/agent.json
ERC-8004 registration file
FAIL type is https://eips.ethereum.org/EIPS/eip-8004#registration-v1
PASS name is set
PASS description is set
WARN image is set (SHOULD, for NFT apps)
FAIL services lists at least one endpoint
WARN registrations links back to an on-chain identity (SHOULD)
FAIL file names this exact on-chain agent (#9765)
FAIL no service named A2A, so there is no agent card to follow
4 failure(s)
Each line states a rule. PASS means it holds, WARN means a "should" is missed, and FAIL means a "must" is. The local file passes with the two warnings you'd expect on a laptop: no on-chain registration yet, and no HTTPS. Agent 9765 fails four checks, and the last is the one this post is about: with no A2A service listed, a client can't get from the identity to a card.
When both sides are filled in, the path runs both ways. From the chain: tokenURI, then the registration file, then services[A2A], then the agent card. From the web: the registration file names its agentRegistry and agentId. For endpoints on other domains, the EIP adds an optional check: the domain hosts /.well-known/agent-registration.json with a matching registration, showing that whoever runs the domain also controls the on-chain identity.
Going deeper
What identity proves, and what it doesn't
An ERC-8004 identity proves that whoever holds one key controls this ID and chose this registration file, and every change is a public event. It doesn't prove the skills work, that the agent is safe, or who the person behind the key is. The EIP says so plainly: it binds the file to the on-chain agent, but can't guarantee the advertised capabilities are functional or non-malicious.
The A2A 1.0 spec lets you sign a card with a JSON Web Signature over a canonical form of the JSON (RFC 7515 and RFC 8785). A signature tells you who published the card and that nobody changed it. It says nothing about whether the card is honest.
Treat card text as untrusted input. If a model reads skill descriptions to choose which agent to call, those descriptions are a prompt-injection surface. Parse cards as data, keep them out of system prompts, and use an allowlist of agents for anything that moves money.
One more catch: tokenURI is only a pointer. If it points to a file you can edit, like the GitHub file above, the identity's description can change with no on-chain transaction. For a fixed record, use a content-addressed ipfs:// URI or a data: URI, and treat an edit as a new setAgentURI call that everyone can see.
Sybil resistance
A Sybil attack is one actor posing as many. Minting an identity costs only gas: my dry run below shows that a register() call from a throwaway address goes through. The Reputation Registry stops an agent's owner and operators from rating their own agent, but a fresh wallet isn't the owner. The EIP admits that fake reputation is possible, and its answer is to make you choose whose opinion counts: getSummary won't run without a list of client addresses, so "the average from everyone" isn't a query you can make on-chain. Build that list from clients you've dealt with, feedback that carries a proofOfPayment, or an aggregator you trust.
Reputation can be gamed
Even honest feedback is a weak signal. A score usually describes one run, and an average of good runs hides the ones that failed. I wrote about this in One run proves nothing: for anything a customer relies on, success every time matters more than one good result. Prefer feedback tied to a skill and a task (the feedback file has a2a.skills and a2a.taskId fields), weight recent results, and run your own evals on any agent you depend on.
Validation: re-execution, TEEs or zkML
| Option | What it shows | The catch |
|---|---|---|
| Re-execution by staked validators | Others ran the same job and agree, with money at stake if they lie | Model output varies between runs, so validators need tolerant checks or fixed settings; incentives and slashing live outside the registry |
| TEE attestation | A specific build of code ran inside a hardware enclave | You trust the hardware maker and its attestation service |
| zkML proof | A specific model produced this output from this input, checked by maths | Proving adds heavy compute on top of inference, so check what your model size allows |
The EIP frames this as security in proportion to the value at risk. Reputation may be enough for ordering a pizza, while a medical diagnosis deserves stake, an enclave or a proof. Validators answer from 0 to 100 and can answer more than once for the same request, for example a soft result followed by a final one.
Privacy
- It's public and permanent. The EIP notes that on-chain pointers and hashes can't be deleted.
- Feedback is a usage record. Each score links a client address to an agent it used. Give your agents their own addresses, not your personal wallet.
- Keep personal data out of every file. No phone numbers or customer names in registration or feedback files, and especially not on IPFS or in a
data:URI, where you can't take them back. - Cards can be layered. A2A lets you publish a minimal public card and serve an extended card only to authenticated clients.
What production needs
HTTPS is a must for production A2A traffic. Add a Cache-Control header next to the ETag the SDK already sends, declare authentication in securitySchemes, sign the card, and log every call with its task ID so feedback can point at something you can inspect.
Your 30-minute build
Publish an agent card for your own agent idea, then validate it. The skill can be plain code; the point is a card another agent can find and understand.
Acceptance checks:
- ☐
curlon/.well-known/agent-card.jsonreturns your card, with one skill that has an id, name, description, tags and an example. - ☐
python client.pyfinds your skill from the card alone and gets a correct reply. - ☐
node check-registration.mjs registration.jsonends withOK. - ☐ The name in your registration file matches the name on your card.
Three ideas to start from:
- Market: a price-board agent for a trader, with a
quote_priceskill that answers from the trader's own price list for tomatoes, pepper and onions. - Transport: a fare splitter for a danfo or keke ride that says who covers the odd naira when the fare doesn't divide evenly.
- School fees: an instalment planner that turns a term's fees and a deadline into equal weekly amounts.
Stretch: register on a testnet
Host registration.json at a public HTTPS URL, then mint an ID for it. The testnet Identity Registry uses the same address on Ethereum Sepolia, Base Sepolia, Celo Sepolia and the other testnets in the deployment list; change the sepolia import and RPC_URL to use another.
// register.mjs: mint an ERC-8004 identity on a testnet (stretch goal)
// Dry run: node register.mjs https://your-host/registration.json
// For real: node --env-file=.env register.mjs https://your-host/registration.json
import { createPublicClient, createWalletClient, http, parseAbi, parseEventLogs } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains";
// Testnet IdentityRegistry: the same address on every testnet listed in erc-8004/erc-8004-contracts
const IDENTITY_REGISTRY = "0x8004A818BFB912233c491871b3d84c89A494BD9e";
const RPC_URL = process.env.RPC_URL ?? "https://ethereum-sepolia-rpc.publicnode.com";
const agentURI = process.argv[2];
if (!agentURI) throw new Error("Pass the URL of your registration file");
const abi = parseAbi([
"function register(string agentURI) returns (uint256 agentId)",
"event Registered(uint256 indexed agentId, string agentURI, address indexed owner)",
]);
const publicClient = createPublicClient({ chain: sepolia, transport: http(RPC_URL) });
if (!process.env.PRIVATE_KEY) {
// No key: simulate the call from a throwaway address. Nothing is signed or sent.
const { result } = await publicClient.simulateContract({
address: IDENTITY_REGISTRY, abi, functionName: "register", args: [agentURI],
account: "0x000000000000000000000000000000000000dEaD",
});
console.log(`dry run on ${sepolia.name}: register() would mint agentId ${result}`);
} else {
const account = privateKeyToAccount(process.env.PRIVATE_KEY);
const walletClient = createWalletClient({ account, chain: sepolia, transport: http(RPC_URL) });
const { request } = await publicClient.simulateContract({
address: IDENTITY_REGISTRY, abi, functionName: "register", args: [agentURI], account,
});
const hash = await walletClient.writeContract(request);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
const [event] = parseEventLogs({ abi, logs: receipt.logs, eventName: "Registered" });
console.log(`tx ${hash}`);
console.log(`registered agentId ${event.args.agentId} owned by ${event.args.owner}`);
console.log(`add {"agentId": ${event.args.agentId}, "agentRegistry": "eip155:${sepolia.id}:${IDENTITY_REGISTRY}"} to registrations`);
}
Without a key, it only simulates the call:
node register.mjs https://example.com/registration.json
dry run on Sepolia: register() would mint agentId 10678
Your number will be higher; IDs are handed out in order. I ran only the dry run, because a real registration needs a funded testnet wallet. To go further: make a new wallet used only for testing, get test ETH from a faucet for your chosen testnet, put PRIVATE_KEY=0x... in .env, add .env to .gitignore, and run node --env-file=.env register.mjs <your URL>. Then add the printed entry to registrations and run the checker again. Never put a key in code, in chat or in a commit.
Help me build and validate an A2A agent card for my own agent idea.
Work in a new folder called my-agent.
My idea: <one sentence: what the agent does and for whom>
My one skill: <skill id, e.g. quote_price>, example request: <e.g. "price of tomatoes">
Stack (pin these exact versions):
- Python 3.11+, a2a-sdk[http-server]==1.2.1, uvicorn==0.54.0
- Node 20+, viem@2.57.3 installed with --save-exact, "type": "module" in package.json
Files:
1. agent.py: a Starlette app on a2a-sdk 1.2.1, served by uvicorn on 127.0.0.1:9999.
One AgentExecutor that answers with plain code (no model, no network calls).
An AgentCard with name, description, version, capabilities,
default_input_modes and default_output_modes ["text/plain"], one AgentInterface
(protocol_binding "JSONRPC", protocol_version "1.0", url http://127.0.0.1:9999/a2a/jsonrpc)
and one AgentSkill with id, name, description, tags and at least one example.
Serve the card with create_agent_card_routes (default path /.well-known/agent-card.json)
and JSON-RPC with create_jsonrpc_routes at /a2a/jsonrpc.
2. client.py: fetch the card with A2ACardResolver, print the skills, send one message
with create_client and print the reply.
3. registration.json: an ERC-8004 registration file with
type "https://eips.ethereum.org/EIPS/eip-8004#registration-v1", name, description, image,
services [{"name": "A2A", "endpoint": "http://127.0.0.1:9999/.well-known/agent-card.json", "version": "1.0"}],
"active": true, "registrations": [] and "supportedTrust": ["reputation"].
4. check-registration.mjs: copy it unchanged from
https://www.edidiongumana.tech/blog/agent-cards-and-onchain-identity/
Acceptance checks:
- curl -s http://127.0.0.1:9999/.well-known/agent-card.json returns my card with my skill.
- python client.py "<my example request>" prints my skill and a correct reply.
- node check-registration.mjs registration.json ends with "OK".
- The name in registration.json matches the card name.
Rules:
- No private keys, no wallets, no real money.
- Don't guess SDK APIs. If an import fails, read the installed package in .venv and fix it.
- Test before you finish: start the server, run every acceptance check, show me the
real output, then stop the server.Learn it properly
Multi-agent patterns, and agents that pay their own way, are part of Track 1 of the AI Study Group, the Agentic AI engineer roadmap. It's free and self-paced, and starts with building a small tool-using agent of your own.
Sources
- A2A protocol specification, version 1.0: agent card fields, discovery, card signing, caching, versioning and security
- A2A: agent discovery: the well-known URI, registries and direct configuration
- a2a.proto (a2aproject/A2A): required fields of
AgentCardandAgentSkill - A2A changelog: the move from
agent.jsontoagent-card.jsonin 0.3.0 - a2a-python, the official Python SDK, and its
samples/hello_world_agent.py - A new chapter for A2A: joining the Agentic AI Foundation (A2A blog, 27 August 2026)
- ERC-8004: Trustless Agents (draft): the three registries, registration file, feedback file, validation and security considerations
- erc-8004/erc-8004-contracts: deployment addresses, ABIs and the Validation Registry status note
- viem: readContract and simulateContract
- Package versions used:
a2a-sdk1.2.1,uvicorn0.54.0,viem2.57.3, Python 3.11, Node 24.18.0