You built an agent card earlier (a small public file that says what an agent can do). Now use it. Agent-to-agent, or A2A, is one agent handing work to another with no person in between, and it is the cell the multi-agent internet is made of. A task envelope goes out (the wrapper around a message: who it is from, who it is to, and what it asks). A worker finds it, does the work, and returns a result you can prove came from them. One agent publishes a task, the other claims it and signs the result. No human on the wire. The message format is the easy part. Trust is the lab.
A tiny worker on port :8801. It serves its agent card at /.well-known/agent-card.json so anyone can find it, and it takes a task envelope at POST /tasks (POST is the HTTP method for sending data to a server). Pure standard library, no framework to install. Save as agent_b.py and leave it running in its own terminal.
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
NAME = "agent-b"
# The card any peer reads to learn what this agent can do.
CARD = {
"name": NAME,
"url": "http://127.0.0.1:8801",
"skills": [
{"id": "summarize", "description": "Summarize a block of text into one line."}
],
}
def summarize(payload):
"""The actual work. Trivial on purpose: first sentence, capped length."""
text = (payload.get("text") or "").strip()
first = text.split(".")[0].strip()
if len(first) > 80:
first = first[:77] + "..."
return {"summary": first or "(empty)", "chars_in": len(text)}
class Handler(BaseHTTPRequestHandler):
def _send(self, code, obj):
body = json.dumps(obj).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/.well-known/agent-card.json":
self._send(200, CARD)
else:
self._send(404, {"error": "not found"})
def do_POST(self):
if self.path != "/tasks":
return self._send(404, {"error": "not found"})
n = int(self.headers.get("Content-Length", 0))
env = json.loads(self.rfile.read(n) or "{}")
if env.get("intent") != "summarize":
return self._send(400, {"error": "unknown intent: " + str(env.get("intent"))})
result = {
"from": NAME,
"to": env.get("from"),
"intent": env.get("intent"),
"result": summarize(env.get("payload", {})),
}
self._send(200, result)
def log_message(self, *a):
pass # quiet the default access log
if __name__ == "__main__":
print("agent-b listening on http://127.0.0.1:8801")
HTTPServer(("127.0.0.1", 8801), Handler).serve_forever()
A task envelope is just four fields: from, to, intent (what you are asking for), and payload (the data to work on). B reads the intent, runs the matching skill, and hands back a result envelope of the same shape. That is the whole protocol. Everything past here is making it trustworthy.
A client that does what a person never should by hand: it reads B's card, confirms B really lists the summarize skill, then sends a task and reads the result. Find it first, send second. Save as agent_a.py and run it in a second terminal while B is up.
import json
import urllib.request
B = "http://127.0.0.1:8801"
def get_json(url):
with urllib.request.urlopen(url, timeout=5) as r:
return json.loads(r.read())
def post_json(url, obj):
data = json.dumps(obj).encode("utf-8")
req = urllib.request.Request(url, data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=5) as r:
return json.loads(r.read())
# 1. discover: read the card, confirm the skill exists before trusting anything.
card = get_json(B + "/.well-known/agent-card.json")
skills = [s["id"] for s in card.get("skills", [])]
print("agent-b advertises:", skills)
assert "summarize" in skills, "agent-b cannot summarize; aborting"
# 2. dispatch: send a task envelope, read the result envelope.
envelope = {
"from": "agent-a",
"to": card["name"],
"intent": "summarize",
"payload": {"text": "Two agents, one protocol, a real handoff. "
"Everything else is just scale."},
}
result = post_json(B + "/tasks", envelope)
print("round trip ok ->", result["result"]["summary"])
$ python agent_a.py agent-b advertises: ['summarize'] round trip ok -> Two agents, one protocol, a real handoff
A sent text, B returned a summary, and A never had to know in advance what B could do. It asked. That is find-it plus send-it, the two halves of every agent handoff. But notice what A is trusting: a blob of JSON that arrived over the network. It has no proof B sent it, and B has no defense if A floods it. That is step 3.
Two upgrades make this real. First, B signs its result. It runs the message and a shared secret through HMAC (a standard recipe that produces a short signature only someone with the secret could make), and A checks that signature before trusting the result. Second, B keeps a receive-budget: a per-sender counter that drops envelopes past N per minute, so one peer cannot flood it. Replace agent_b.py with this version, then run the updated agent_a.py below it.
import hashlib
import hmac
import json
import time
from collections import deque
from http.server import BaseHTTPRequestHandler, HTTPServer
NAME = "agent-b"
SECRET = b"shared-lab-secret" # both agents hold this; in prod use a real key exchange
BUDGET = 5 # max envelopes per sender per window
WINDOW = 60 # seconds
CARD = {
"name": NAME,
"url": "http://127.0.0.1:8801",
"skills": [
{"id": "summarize", "description": "Summarize a block of text into one line."}
],
}
SEEN = {} # sender -> deque of recent timestamps
def within_budget(sender):
now = time.time()
q = SEEN.setdefault(sender, deque())
while q and now - q[0] > WINDOW:
q.popleft()
if len(q) >= BUDGET:
return False
q.append(now)
return True
def canonical(body):
# Sort keys + no spaces so both sides hash the SAME bytes. Order matters.
return json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
def sign(body):
return hmac.new(SECRET, canonical(body), hashlib.sha256).hexdigest()
def summarize(payload):
text = (payload.get("text") or "").strip()
first = text.split(".")[0].strip()
if len(first) > 80:
first = first[:77] + "..."
return {"summary": first or "(empty)", "chars_in": len(text)}
class Handler(BaseHTTPRequestHandler):
def _send(self, code, obj):
data = json.dumps(obj).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if self.path == "/.well-known/agent-card.json":
self._send(200, CARD)
else:
self._send(404, {"error": "not found"})
def do_POST(self):
if self.path != "/tasks":
return self._send(404, {"error": "not found"})
n = int(self.headers.get("Content-Length", 0))
env = json.loads(self.rfile.read(n) or "{}")
sender = env.get("from", "anon")
# anti-spam: drop the over-budget sender before doing any work
if not within_budget(sender):
return self._send(429, {"error": "over budget", "from": NAME, "to": sender})
if env.get("intent") != "summarize":
return self._send(400, {"error": "unknown intent: " + str(env.get("intent"))})
body = {
"from": NAME,
"to": sender,
"intent": env.get("intent"),
"result": summarize(env.get("payload", {})),
}
# attribution: sign the canonical body so A can prove this came from B
self._send(200, {"body": body, "sig": sign(body)})
def log_message(self, *a):
pass
if __name__ == "__main__":
print("agent-b (signed + budgeted) on http://127.0.0.1:8801")
HTTPServer(("127.0.0.1", 8801), Handler).serve_forever()
import hashlib
import hmac
import json
import urllib.error
import urllib.request
B = "http://127.0.0.1:8801"
SECRET = b"shared-lab-secret" # same secret B holds
def canonical(body):
return json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
def verify(body, sig):
expected = hmac.new(SECRET, canonical(body), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig) # constant-time compare
def post(envelope):
data = json.dumps(envelope).encode("utf-8")
req = urllib.request.Request(B + "/tasks", data=data,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=5) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
envelope = {"from": "agent-a", "to": "agent-b", "intent": "summarize",
"payload": {"text": "Sign the result so the peer can prove who sent it."}}
# happy path: signature must verify before we trust the result
code, reply = post(envelope)
ok = verify(reply["body"], reply["sig"])
print("request 1:", code, "signature valid:", ok)
assert ok, "signature did not verify -- do NOT trust this result"
print(" summary:", reply["body"]["result"]["summary"])
# spam path: same sender, fire 6 in a row; the 6th trips the budget (N=5)
print("hammering with 6 requests from the same sender...")
for i in range(2, 8):
code, reply = post(envelope)
note = reply.get("error") if code == 429 else "accepted"
print(" request " + str(i) + ":", code, note)
$ python agent_a.py request 1: 200 signature valid: True summary: Sign the result so the peer can prove who sent it hammering with 6 requests from the same sender... request 2: 200 accepted request 3: 200 accepted request 4: 200 accepted request 5: 200 accepted request 6: 429 over budget
The signature is computed over the canonical body. Canonical just means both sides build the exact same bytes first, with keys sorted and spaces removed, so the two signatures line up. Change one character of the body in flight and verify returns false. The budget counts five from one sender per minute and drops the sixth before B does a lick of work. Two short functions buy you proof-of-sender and a flood ceiling.
Anyone can POST you a JSON envelope. Without two things, agent-to-agent is a spam cannon and a confused deputy waiting to happen. (A confused deputy is a trusted helper tricked into using its own authority for someone else.) First, attribution: proof of who actually sent this, by a signature, not a claim typed into a "from" field that any caller can fake. A bare from is a name tag written in pencil. Second, a receive-budget: how many envelopes you will take from one sender before you start dropping them, so a single peer cannot drown you. The protocol is maybe 20 percent message format and 80 percent who-can-do-what.
This is not theory. It is exactly what the Immersive Commons agent-inbox enforces in production: auth built in deliberate layers, frozen scopes that do not silently widen when a tier upgrades, a receive-budget to stop spam, and message as the basic unit of a conversation. When the two agents live on different machines and you need cryptographic proof of who sent what, that is what the CAP protocol (Claude Agent Protocol) adds on top of this same shape.
Build the message format in an afternoon. Spend the rest of the week on the two checks that decide whether a stranger's envelope gets to run.
Wire agent-A to discover agent-B's agent-card, confirm it has the skill I need, send it a task envelope, and verify the hmac signature on the result before trusting it. Add a receive-budget to B that drops envelopes past N per minute from a single sender, and show me both the happy path and a rejected over-budget request.
Two agents complete a task across the network, with proof of who sent it and a spam budget. A found B from its card, confirmed the skill, sent a task, and checked the signed result before trusting it. B dropped the over-budget sender before doing any work. That is the cell the multi-agent internet is made of, and you just built one. Next: stop talking to one agent and start conducting ten.