Useful A2A extensions
The urn:agentenv extensions the framework relies on to provide full functionality
Extensions are what an agent supports beyond plain A2A messages, such as taking environments or handing back what it did. Each one is listed on the agent card, and the framework uses it only when the card lists it, so one task can drive agents that support different things.
The extensions
The SDK defines eight. It implements some of them for you; for the rest, you implement what your agent does:
| Extension | What the framework does with it | Implemented by |
|---|---|---|
agent-config/v1 | prompt_agent sends the settings a step sets, such as model and system_prompt, before its prompt; deploy_agent sets name, description, role and system_prompt | The SDK, from your config class |
mcp-config/v1 | deploy_agent posts the MCP URL of each env it is given | The SDK |
trajectory/v1 | prompt_agent reads the trajectory of each task once the agent replies | The SDK, from the trajectory run() returns |
skill-config/v1 | deploy_agent, add_skills and a2a-agent add-skill install skills | You install, the SDK lists; see Skills |
peer-agents/v1 | peer_agents sends each agent the other agents it can message | You |
snapshot/v1 | snapshot_agent_state saves the agent's state as an artifact; deploy_agent loads it into a new instance | You |
triggers/v1 | register_agent_triggers registers rules; prompt_agent asks the agent which fire on each turn when the agent plays the user | The SDK |
install/v1 | install_agent runs the install commands it declares inside a container a task already runs, instead of deploying the agent's image | You declare the commands |
Declaring one
Declare an extension the SDK implements in extensions on @a2a_agent, as ClaudiusRLMAgent
declares MCP_CONFIG_V1 and TRAJECTORY_V1; its config class turns on agent-config by itself. For
one you implement, bind a method to each of its operations with @extension, and the card lists it.
Bind all of an extension's operations or none of them.
agent-config
agent-config lets you configure an agent, such as its model, effort or system prompt. Every agent
takes these settings the same way, and how it applies them is up to the agent. In a task, a
prompt_agent step that sets model to claude-sonnet-5-5 moves the agent to Sonnet from its next
request.
mcp-config
mcp-config lets you give an agent environments: any MCP server, by URL. In a task, deploy_agent
adds each env it names, so claudius sees the email env as the MCP server env4276. Each task
hands the agent its servers, and how it uses their tools is up to the agent.
trajectory
trajectory lets you see what an agent did in each task, in the agent's own format. claudius
returns its tool calls as claudius/v1 at the end of
run(). In a task, prompt_agent fetches the trajectory
after each reply and stores it with the run, where a grader such as rubrics_verifier reads it.
skill-config
skill-config lets you install skills on an agent, from a task or the command line. deploy_agent
installs them right after the deploy, add_skills later in a task, and agent-env a2a-agent add-skill on any running instance. Skills covers the handlers you write.
peer-agents
peer-agents lets the agents in a task message each other. A peer_agents step tells an agent who
its peers are, and you implement what the agent does with them. ClaudiusRLMAgent keeps its peers
and gains a method that messages one:
from urllib.parse import urljoin
from uuid import uuid4
import httpx
from agentenv_protocol.a2a_agent import PEER_AGENTS_V1, PeerAgentsSetRequest, extension
class ClaudiusRLMAgent(AgentEnvAgent):
def __init__(self) -> None:
self.peers = {}
@extension(PEER_AGENTS_V1.set)
async def set_peers(self, request: PeerAgentsSetRequest):
self.peers = {peer.name: peer for peer in request.peers}
return {"status": "updated"}
@extension(PEER_AGENTS_V1.list)
async def list_peers(self):
return {"peers": [peer.model_dump() for peer in self.peers.values()]}
async def ask_peer(self, name: str, text: str) -> str:
"""Send a peer one A2A message and return its reply."""
peer = self.peers[name]
message = {"kind": "message", "messageId": uuid4().hex, "role": "user", "parts": [{"kind": "text", "text": text}]}
async with httpx.AsyncClient(timeout=600) as client:
response = await client.post(
urljoin(peer.url, peer.card.get("url", "/a2a")),
json={"jsonrpc": "2.0", "id": 1, "method": "message/send", "params": {"message": message}},
)
task = response.json()["result"]
return "".join(part["text"] for part in task["status"]["message"]["parts"] if part["kind"] == "text")Offer ask_peer to Claude as a tool, and the agent can hand part of a task to a peer. Peering is
one way, so for finance to message assistant as well, make it a source too. Peers must reach
each other's A2A URLs, so run them on a sandbox such as modal; on local, one agent's container
cannot reach another's.
snapshot
snapshot lets you save an agent's conversation and pick it up later in a new instance. Only your
agent knows what it holds, so you implement save and load. These additions give
ClaudiusRLMAgent conversations to save:
import io
import json
import tarfile
import tempfile
from pathlib import Path
from uuid import uuid4
from agentenv_protocol.a2a_agent import (
SNAPSHOT_V1,
ObjectSnapshotLoadRequest,
ObjectSnapshotSaveRequest,
ObjectSnapshotSaveResponse,
SnapshotUploadedObjects,
download,
extension,
upload,
)
WORKSPACE = Path("/app/workspace")
def archive(directory: Path) -> bytes:
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as tar:
tar.add(directory, arcname=".")
return buffer.getvalue()
class ClaudiusRLMAgent(AgentEnvAgent):
def __init__(self) -> None:
self.conversations = {}
WORKSPACE.mkdir(parents=True, exist_ok=True)
@extension(SNAPSHOT_V1.save)
async def save_snapshot(self, request: ObjectSnapshotSaveRequest):
conversation = json.dumps(self.conversations.get(request.context_id, [])).encode()
objects = {"trajectory": await upload(request.objects.trajectory, conversation)}
if request.objects.workspace is not None:
objects["workspace"] = await upload(request.objects.workspace, archive(WORKSPACE))
return ObjectSnapshotSaveResponse(
context_id=request.context_id, objects=SnapshotUploadedObjects(**objects)
)
@extension(SNAPSHOT_V1.load)
async def load_snapshot(self, request: ObjectSnapshotLoadRequest):
with tempfile.TemporaryDirectory() as tmp:
await download(request.objects.trajectory, Path(tmp, "trajectory"))
conversation = json.loads(Path(tmp, "trajectory").read_bytes())
if request.objects.workspace is not None:
await download(request.objects.workspace, Path(tmp, "workspace"))
with tarfile.open(Path(tmp, "workspace")) as tar:
tar.extractall(WORKSPACE, filter="data")
context_id = request.target_context_id or uuid4().hex
self.conversations[context_id] = conversation
return {"context_id": context_id}run() then starts each task from self.conversations[request.context_id] and saves the messages
back. In a task, snapshot_agent_state saves the snapshot as an artifact, and a later
deploy_agent loads it into a new instance. Snapshots move through signed URLs, so they need an
object store such as S3.
triggers
triggers lets you script the user in a multi-turn conversation. When a prompt_agent step runs
several turns, an agent such as user-sim plays the user, and rules you give it answer for it, such
as confirming when the agent asks. Declare TRIGGERS_V1 on that agent, and the SDK evaluates the
rules a register_agent_triggers step gives it:
{"id": "rules", "type": "register_agent_triggers", "agent_name": "user-sim",
"triggers": [
{"id": "confirm", "when": {"type": "conversational", "where": {"message": {"regex": "Send it\\?"}}},
"actions": [{"type": "say", "text": "Yes, send it."}]},
{"id": "sent", "when": {"type": "conversational", "where": {"message": {"regex": "Sent"}}},
"actions": [{"type": "say", "text": "Thanks."}, {"type": "end"}]}]}A rule's when is one of four kinds:
step, a turn number, wherecmpiseqorgteconversational, aregex,equalsorexiststest on the agent's replyenv_trigger, the status of a trigger that aregister_env_triggersstep registered on an env (see Triggers)alloranyof other rules
Each rule fires at most once per conversation unless it sets "once": false.
install
install lets a task run an agent inside a container it already runs, instead of in a sandbox of its
own. The agent declares the shell commands that install and start it, and install_agent runs them.
claudius does not declare it; these additions do:
from agentenv_protocol.a2a_agent import INSTALL_V1, enable
INSTALL = enable(
INSTALL_V1,
install_commands=[
"docker exec {container} mkdir -p /opt/claudius",
"docker cp {agent_ctx_tar} {container}:/opt/claudius/claudius.tar.gz",
"docker exec -w /opt/claudius {container} sh -c 'tar -xzf claudius.tar.gz && python3 -m venv .venv"
" && .venv/bin/pip install \"agentenv-framework-protocol[agent]\" \"mcp>=1.25,<2\" anthropic'",
"docker exec -d -w /opt/claudius -e A2A_PORT={a2a_port} \\\n"
" -e LITELLM_BASE_URL={litellm_base_url} -e LITELLM_API_KEY={litellm_api_key} {container} .venv/bin/python agent.py",
],
required_params=["container", "agent_ctx_tar", "a2a_port", "litellm_base_url", "litellm_api_key"],
a2a_port=8000,
)
@a2a_agent(identity=..., config=ClaudiusRLMConfig, extensions=(MCP_CONFIG_V1, TRAJECTORY_V1, INSTALL))
class ClaudiusRLMAgent(AgentEnvAgent):
...The commands reach the container with docker cp and docker exec, and install_agent fills in
placeholders such as {container} and {a2a_port}. It also reads the card from an a2a_server.py
in the image, holding AGENT_CARD = A2AAgentApplication(ClaudiusRLMAgent()).card.
Your own extensions
For anything else, @custom_extension adds an extension of your own, under a URN outside
urn:agentenv:. The card lists it like the others, and your own steps or clients call it.
Last updated on