# Useful A2A extensions (https://www.agentenvframework.com/docs/agents/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](https://www.agentenvframework.com/docs/agents/skills.md) |
| `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

1. `claudius` serves agent-config, listing the fields `ClaudiusRLMConfig` takes, such as `model` and `effort`.
2. Before its prompt, `prompt_agent` sets the model and effort its step names: `claude-opus-5-5`, at `medium`.
3. `run()` reads them from `request.config` and sends them to the model provider with every request.
4. A later `prompt_agent` sets only `model`, to `claude-sonnet-5-5`. The SDK keeps the effort set before it.
5. The next task’s request goes to Sonnet, still at `medium` effort.

Parts of the scene:

- **A prompt_agent step**: Before it sends its prompt, it posts the settings its step sets to agent-config, with the model the run uses. It leaves out any field the card does not list.
- **A later prompt_agent step**: Sets only `model`. A set changes only the fields it names, so the effort the first step set stays.
- **The agent instance**: `claudius-4msj7n4d`, version 1 of `claudius` from Deploying your agent.
- **The SDK’s server**: `serve()` answers `POST /ext/agent-config`, checks each set against `ClaudiusRLMConfig` and keeps the fields set so far. The card lists every field the class takes.
- **request.config**: Built for each task: the class’s defaults with every field set so far on top. `ClaudiusRLMConfig` defaults to `claude-opus-5-5` at `high` effort.
- **The model provider**: The endpoint at `LITELLM_BASE_URL`, such as a LiteLLM proxy. `ClaudiusRLMAgent` calls it through the Anthropic SDK with the config’s `model`, and its `effort` in `output_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

1. `deploy_env` deploys `email`. The instance serves a card named `env4276`, with its MCP endpoint at `/mcp`.
2. `deploy_agent` deploys `claudius`, whose card lists mcp-config with `name` optional in `add`. It takes the env card’s name and the MCP URL as the agent’s sandbox reaches it.
3. It posts both to `/ext/mcp-config`. The SDK keeps the server under that name and answers `added`; a URL or a name it already holds gets a 409.
4. A `GET` on the same endpoint lists each server by name, with its URL and `has_headers`, never the header values.
5. `prompt_agent` sends the task, and `run()` gets the servers in `request.mcp_servers`, headers included. `ClaudiusRLMAgent` connects to each over MCP and lists its tools.
6. Claude does the task with the env’s tools: `email_search` finds Dana’s email and `send` replies to Sam Lee.

Parts of the scene:

- **deploy_env**: A task step that deploys an env and adds its instance’s record, with the card the instance served, to the task’s `context.deployed_envs`.
- **The env instance**: `email-mmtflq6t`, from Deploying your environment, running in the local sandbox at `http://localhost:61655`.
- **What deploy_agent reads**: The card on the env’s record: its name, `env4276`, which is drawn anew on every deploy, and the MCP URL, the instance’s URL joined with the card’s `/mcp`.
- **The env’s tools**: The tools the card advertises, `email_search` and `send`. An MCP client lists and calls them at `/mcp`.
- **deploy_agent**: A task step that deploys the agent, then, when its card lists mcp-config, posts one `add` for each env in its `env_ids`. An `add` the agent refuses fails the step.
- **The add request**: The URL is the env’s MCP URL as the agent reaches it: local sandboxes share no Docker network, so `localhost` becomes `host.docker.internal`. The name is the env card’s, sent because the agent’s card lists `name`. Headers go only when a sandbox provider declares some for the URL’s host, and `local` declares none.
- **mcp-config on the card**: Declaring `MCP_CONFIG_V1` lists `urn:agentenv:mcp-config/v1` on the card with its endpoint and operations: `add` takes a `url`, and optionally `headers` and a `name`; `list` answers `mcp_servers`.
- **What the SDK keeps**: Each server’s URL and headers, by name, in the running instance’s memory. An `add` without a `name` gets one of the form `mcp_` and eight hex characters.
- **Any client**: Anything that can send a `GET`, such as `curl`, can read what an instance was given. The list says whether each server has headers, not what they are.
- **prompt_agent**: A task step that sends the prompt to the card’s `url`, `/a2a`, as an A2A message. Each task becomes one call to `run()`.
- **The SDK’s server**: `serve()` answers `add` and `list` at `/ext/mcp-config` and keeps what `add` sends; you write none of it. It hands every task a copy of the servers in `request.mcp_servers`.
- **Your agent**: `ClaudiusRLMAgent`. For each entry in `request.mcp_servers`, `run()` opens an MCP session with its `url` and `headers`, lists the tools and offers them to Claude.
- **The agent instance**: `claudius-4msj7n4d`, from Deploying your agent, running in the local sandbox at `http://localhost:42031`.
- **The MCP session**: The agent connects to the URL it was given, and no framework code sits in between. Each tool Claude calls is one MCP call on this session.

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

1. The card lists trajectory with one operation, `get`: a `POST` to `/ext/trajectory` with a `task_id`. `claudius` declares `TRAJECTORY_V1`, so the SDK serves it; the task’s `deploy_agent` named the card `assistant`.
2. `prompt_agent` sends `assistant` the task at `/a2a`. `run()` adds each tool call Claude makes to `calls`, at depth 1 inside the sub-task it hands to `recurse`.
3. `run()` returns the answer and `calls` as the result’s native trajectory, in the format `claudius/v1`. The SDK keeps it under the task’s id, then completes the task.
4. Once the reply is in, `prompt_agent` posts the task’s id to `/ext/trajectory`. The SDK answers with the payload itself, without calling `run()`.
5. `prompt_agent` stores the trajectory in the object store and records its URL on the run’s `PromptResponse` for `q3`, as `agent_trajectory_s3_uri`.
6. With an object store that issues upload grants, such as S3, `prompt_agent` sends one in `objects` and the SDK uploads the trajectory itself. The local store issues none.

Parts of the scene:

- **The instance’s card**: Served at `/.well-known/agent-card.json` by `claudius-4msj7n4d`. The task’s `deploy_agent` posted its `agent_name`, `assistant`, to agent-config, which renamed the card. `prompt_agent` reads the trajectory only when the card lists the extension, and finds its endpoint here.
- **trajectory/v1**: `urn:agentenv:trajectory/v1`, listed with its endpoint and its one operation, `get`. The SDK implements both, so `claudius` writes no endpoint for it.
- **get**: A `POST` whose body has the `task_id` and, optionally, `objects`: a grant to upload the trajectory through. The protocol also defines a form by `context_id`, which the SDK does not serve: it answers that with a 400.
- **prompt_agent**: A task step that sends the prompt to the agent it names, `assistant`, at `/a2a` and waits for the reply. When the card lists trajectory, it then reads the trajectory of that task; a read that fails is logged, and the step carries on.
- **The get request**: `{"task_id": "dc9e1d94-…"}`, the id the SDK gave the task when `prompt_agent` sent it. The answer is `{"trajectory": …}`: the payload as `run()` returned it, without its format.
- **The agent instance**: `claudius-4msj7n4d`, version 1 of `claudius` from Deploying your agent, running in the local sandbox at `http://localhost:42031`. In this task it is `assistant`.
- **The SDK’s server**: `serve()` answers `/a2a` and `/ext/trajectory`, and turns each A2A task into one call to `run()`. You write none of it.
- **What the SDK keeps**: The trajectory of each task whose `run()` returned one, by the task’s A2A id, in the instance’s memory. It holds at most 1,024 and drops the least recently used; a `get` for a task it does not hold gets a 404.
- **Your agent**: `ClaudiusRLMAgent`. `run()` answers the task with Claude and the `email` env’s tools, and hands sub-tasks to fresh calls of itself through `recurse`.
- **The trajectory**: `claudius/v1` is the agent’s own format: a list of tool calls, each with its `depth`, `tool`, `input` and `output`. A call is added when it returns, so the `email_search` inside the sub-task comes before the `recurse` that made it.
- **native_trajectory**: Sets the result’s trajectory: a `format` naming its shape, any JSON value as the `payload`, and a `version`, 1 unless you pass another. The SDK copies the payload when the result is built.
- **The object store**: The one `[stores.object]` configures, by default the local filesystem under `~/.local/state/agent-env/object_store`. The trajectory goes under `prompt_agent_trajectories/prompt_id=q3/`, or the step’s `trajectory_output_prefix`, named by the id of the message `prompt_agent` sent.
- **PromptResponse**: What `prompt_agent` adds to the run’s `context.prompt_responses` under its `prompt_id`: the reply, the tool-call count and the trajectory’s URL. `rubrics_verifier` reads the trajectory from there and, by default, fails without one.
- **An upload grant**: A signed `PUT` URL for the object `prompt_agent` will record, for JSON up to 1 GiB. The SDK uploads the payload through it and answers with its size and SHA-256. S3 over HTTPS issues grants, and so does Cloud Storage with a signer.

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()`](https://www.agentenvframework.com/docs/agents/creating.md#the-run-method). 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

1. A later version of `claudius` binds a handler to each form of `add`, so its card lists skill-config with both forms at `/ext/skill-config`. The SDK serves `list` itself.
2. Each of the three installers can send either form. Here `deploy_agent` posts `email-etiquette` inline, with the text of its `SKILL.md`, and `add_skills` sends the stored skill `q3-report` as a bundle, a download grant for its one file.
3. `a2a-agent add-skill` sends `email-etiquette` again. The SDK answers 409 before any handler runs, because a name is installed once per instance.
4. A `GET` on `/ext/skill-config` lists each skill by name with its description, and the card lists each as `skill-<name>`.

Parts of the scene:

- **deploy_agent**: A task step that deploys the agent and, when its card lists skill-config, posts one `add` per entry in its `skills`: inline with `skill_md`, or as a bundle with `skill_s3_url`. On a card without skill-config, it skips them.
- **add_skills**: A task step that installs skills on an agent deployed earlier in the run, found by `agent_name`: stored ones by `skill_artifact_id`, and inline ones it renders from `name`, `description` and `body`. It fails when the card does not list skill-config.
- **a2a-agent add-skill**: `agent-env a2a-agent add-skill --instance-id …` installs one skill on a deployed instance: inline with `--skill-md-path`, or as a bundle with `--skill-artifact-id`. A refused `add` fails the command with the agent’s status and message.
- **Any client**: Anything that can send a `GET`, such as `curl`, reads what an instance has installed. `agent-env a2a-agent validate` does too, after it installs test skills, to record whether `add` and `list` work.
- **The inline form**: `name`, `description` and the whole `SKILL.md` as `skill_md`. The name starts with a letter or a digit; a body that matches neither form, or both, gets a 400.
- **The bundle form**: `name`, `description` and `skill_bundle`: each file’s path with a read grant, a signed HTTPS URL that expires, and a root `SKILL.md` among them. Only an object store that signs URLs, such as S3, can send one; on the local filesystem store the call stops before it is sent.
- **Refused**: The SDK checks the name before it calls your handler, against the installed skills and any on `AgentIdentity`, and answers `409 Skill 'email-etiquette' is already registered`. It takes one `add` at a time, so two at once cannot both pass.
- **skill-config on the card**: `urn:agentenv:skill-config/v1`, with `add` in two forms and `list`. `add` is one operation with a handler per form, and the SDK will not serve an agent that binds only one of them.
- **add**: `POST /ext/skill-config`, which you implement, one handler per form. The SDK checks the body against the two forms, refuses a name it already has, and calls the handler for the form that matched.
- **list**: `GET /ext/skill-config`, which the SDK implements: `{"skills": {<name>: {"description": …}}}` for every skill on `AgentIdentity` or installed since the instance started.
- **What the SDK keeps**: Once a handler returns, each skill’s name and description, and an inline one’s `SKILL.md`, in the running instance’s memory. A bundle’s grants are secret and short-lived, so they are not kept; a handler that fails records nothing.
- **On the card**: The SDK adds each installed skill to the card’s `skills`, with the id `skill-<name>`, its description and the tag `skill`, so anything that reads the card sees it.
- **The SDK’s server**: `serve()` answers `/ext/skill-config`: it checks each `add`, calls your handler, records the skill and serves the list. It hands every later task the skills in `request.skills`.
- **Your handlers**: `add_inline_skill` writes `skill_md` to a file; `add_bundle_skill` downloads each file with the SDK’s `download()`, which checks its size and checksum. Each returns the skill’s `name`.
- **Where the agent keeps them**: Up to the agent, since only it knows where its runtime reads skills from. `ClaudiusRLMAgent` writes each skill’s files under `/app/skills/<name>/`.
- **Your agent**: `ClaudiusRLMAgent` with the additions from Skills: two `add` handlers, and a `run()` that reads the skills they wrote.
- **The agent instance**: A later version of `claudius` with the two `add` handlers from Skills, deployed by the task’s `deploy_agent`. The version from Deploying your agent binds no `add` handler, so its card does not list skill-config. In this task it is `assistant`.

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](https://www.agentenvframework.com/docs/agents/skills.md) covers the handlers you write.

## peer-agents

1. Two `deploy_agent` steps deploy `claudius` as `assistant` and another agent as `finance`, each in a `modal` sandbox. The task knows each by its `agent_name`.
2. `peer_agents` gives `assistant` the peer `finance`. Before it sends anything, it checks that both are deployed and that `assistant`’s card lists peer-agents.
3. It posts `finance`’s name, A2A URL and card to `/ext/peer-agents`. `serve()` validates the body and calls `set_peers`, which keeps them.
4. `prompt_agent` sends the task. Claude has `ask_peer` next to the env’s tools, and asks `finance` for the Q3 numbers with an A2A `message/send`.
5. `finance`’s answer comes back as the result of `ask_peer`. Claude replies to Sam Lee with `send`, and the task completes.

Parts of the scene:

- **deploy_agent**: A task step that deploys `claudius` with `sandbox_type` `modal` and adds it to the task’s `deployed_agents` under its `agent_name`, `assistant`, with the card it served. The card lists `name` in agent-config, so the step also sets the card’s name to `assistant`.
- **deploy_agent**: Another `deploy_agent` step, with `agent_name` `finance`, also on `modal`. Every agent a `peer_agents` step names must be deployed in the same run, each under its own name.
- **peer_agents**: A task step whose `peerings` give each source the agents it may message; here `assistant` gets `finance`. Peering is one way: `finance` gets no list unless it is a source too. The step records the peerings in the run’s metadata as `agent_peerings`.
- **prompt_agent**: A task step that sends the prompt to the agent it names, `assistant`, as an A2A message at `/a2a`. Each task becomes one call to `run()`.
- **The agent instance**: `claudius-i70my3d1`, a later version of `claudius` with the peer-agents additions, which the task’s `deploy_agent` deployed in a `modal` sandbox. Its A2A URL is its HTTPS tunnel on `modal.host`. In this task it is `assistant`.
- **peer-agents on the card**: Binding `set_peers` and `list_peers` with `@extension` lists `urn:agentenv:peer-agents/v1` on the card, with its endpoint, `/ext/peer-agents`. `peer_agents` refuses a source whose card does not list it.
- **set**: `POST /ext/peer-agents` with `peers`. `serve()` validates the body as a `PeerAgentsSetRequest`, answers 400 to a field it does not define, and calls `set_peers` with it.
- **list**: `GET /ext/peer-agents` calls `list_peers`, which must answer `peers`. `verify_a2a_peer_agents` checks that peers set through `set` come back from `list`.
- **The SDK’s server**: `serve()` answers the card and each extension endpoint, and turns each A2A task at `/a2a` into one call to `run()`. For peer-agents it keeps nothing: it calls the methods you bound.
- **Your agent**: `ClaudiusRLMAgent` with the additions in this section: `set_peers`, `list_peers` and `ask_peer`. `envs` is the email env, whose MCP URL `deploy_agent` posted through mcp-config. As Creating your agent writes it, the agent does not implement peer-agents.
- **What set_peers keeps**: One `PeerAgent` per peer: its `name`, the `agent_name` it was deployed under; its `url`, the A2A URL the framework recorded; its `card`; and the card’s `description`. On `modal` the URL is the peer’s tunnel on `modal.host`, which `assistant` can reach.
- **Claude’s tools**: The env’s `email_search` and `send`, `recurse`, and `ask_peer`, which sends a peer one message and returns its reply. How an agent uses its peers is up to the agent.
- **The A2A message**: `ask_peer` posts a JSON-RPC `message/send` to the peer’s URL joined with its card’s `url`, `/a2a`, as `prompt_agent` does. No framework code sits in between, so the URL must be reachable from `assistant`’s sandbox. On `local` it is a `localhost` port on your machine, which one agent’s container cannot reach.
- **The peer**: Any A2A agent, here one deployed as `finance` in a `modal` sandbox, at its tunnel on `modal.host`. Its card need not list peer-agents: `peer_agents` checks only each source’s card.

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:

```python title="claudius/agent.py (additions)"
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

1. A later version of `claudius` binds `save` and `load` with the addition below, so its card lists `snapshot/v1`. This instance has answered `q3` and keeps that conversation.
2. `snapshot_agent_state` posts the context of `q3` to `save`, with an upload grant for each object. Grants are signed URLs, so the store must issue them, as S3 does.
3. `save_snapshot` uploads the conversation as `trajectory` and the workspace as `workspace`, straight to the store, and answers with each one’s size and SHA-256.
4. Once both objects are in the store, the step registers them, where they are, as version 1 of the artifact `q3-snapshot`.
5. A later `deploy_agent` with `agent_snapshot_files_artifact_id` deploys a fresh instance and sends `load` a download grant for each object. `load_snapshot` restores them into `q3-restored`, the context the step names.
6. A `prompt_agent` with the `context_id` `q3-restored` continues the conversation: `run()` starts from the messages it loaded.

Parts of the scene:

- **The agent instance**: `claudius-wv275xw7`, an instance of a later version of `claudius`, with the snapshot addition, deployed by the task’s `deploy_agent`. The version from Deploying your agent binds neither operation, so its card does not list `snapshot/v1`. In this task it is `assistant`.
- **The SDK’s server**: `serve()` answers the card, `/a2a` and `/ext/snapshot`, and turns each A2A task into one call to `run()`. For snapshot it keeps nothing: it validates each body and calls the method you bound.
- **snapshot/v1 on the card**: `urn:agentenv:snapshot/v1`, with `save` and `load` at `/ext/snapshot`. The card lists it because the agent binds both. Without it, `snapshot_agent_state` fails, and so does a `deploy_agent` that is given a snapshot to load.
- **save**: `POST /ext/snapshot` with the `context_id` to save and `objects`: an upload grant for `trajectory` and, optionally, one for `workspace`. The answer gives the `context_id` and each uploaded object’s `size_bytes` and `sha256`.
- **load**: `PUT /ext/snapshot` with `objects`, a download grant for `trajectory` and optionally one for `workspace`, and optionally a `target_context_id`. The answer is the `context_id` the agent restored them into.
- **Your agent**: `ClaudiusRLMAgent` with the additions in this section: `save_snapshot`, `load_snapshot`, and a `run()` that starts each task from the conversation it keeps. As Creating your own agent writes it, the agent keeps nothing between tasks.
- **Your handlers**: `save_snapshot` uploads the context’s messages and a tar of the workspace with the SDK’s `upload()`; `load_snapshot` downloads both with `download()` and keeps the messages under the context it answers with.
- **The conversation**: With the addition, `run()` keeps each A2A context’s messages in `self.conversations` and starts every task in that context from them. `save` uploads one context’s messages as the `trajectory` object.
- **The workspace**: The directory the agent works in, `/app/workspace`. `save` uploads it as a tar archive, the `workspace` object, and `load` extracts it again.
- **snapshot_agent_state**: A task step that finds the agent by `agent_name` and the context of a reply by its `prompt_id`, asks the agent to save it, and stores the result under `artifact_id`. It fails when the card does not list `snapshot/v1`.
- **Upload grants**: One signed `PUT` URL per object, valid for an hour, under `agent_snapshots/q3-snapshot/1-…/`. The agent never holds storage credentials; `upload()` keeps to each grant’s limit, 1 GiB for `trajectory` and 5 GiB for `workspace`.
- **Which stores can serve it**: Only an object store that signs URLs, such as S3 or Cloud Storage. On the local filesystem store, the step stops with `Agent 'assistant' advertises no snapshot save form this object store can serve`.
- **The object store**: The one `[stores.object]` configures, here S3. The agent’s uploads land under a fresh prefix for each snapshot, named by the artifact id, the version and eight random characters.
- **trajectory and workspace**: Opaque bytes, `application/octet-stream`, in whatever format the agent chooses. The framework reads neither; it registers the snapshot only once both are in the store, and fails the step otherwise.
- **The snapshot artifact**: A `file_artifact_universe` that registers the objects already under the prefix, without downloading or copying them. Each snapshot to the same id is a new version; the step records it in `context.metadata["agent_snapshots"]`.
- **deploy_agent**: With `agent_snapshot_files_artifact_id`, and optionally its version, it deploys the agent, then sends `load` a download grant for each object, with `agent_snapshot_target_context_id` as the `target_context_id`.
- **Download grants**: One signed `GET` URL per object, valid for an hour, with the object’s size. `download()` checks the bytes against it and writes the file only when they match.
- **The new instance**: `claudius-ap47r93o`, another instance of the same later version of `claudius`, deployed by a later `deploy_agent`: in another run, or in this one under another `agent_name`. It starts with no conversation; the step records the context it loaded in `context.metadata["agent_loaded_snapshots"]`.
- **prompt_agent**: With `context_id` set, it sends its prompt in that A2A context instead of a new one, so the agent answers with the loaded messages before it.

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:

```python title="claudius/agent.py (additions)"
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

1. `register_agent_triggers` checks that `user-sim`’s card lists triggers and posts two rules to `/ext/triggers`, where the SDK keeps them.
2. `prompt_agent` sends q3 to `claudius` at `/a2a`. `claudius` finds Dana’s email, drafts an email to Sam Lee and asks before it sends it.
3. `prompt_agent` posts the turn and `claudius`’s reply to `/ext/triggers/decide` on `user-sim`. The reply matches `confirm`, so the SDK answers with its text.
4. “Yes, send it.” is the next prompt to `claudius`, and `user-sim`’s `run()` is not called. `claudius` sends the email to Sam Lee.
5. On turn 2, `sent` fires with `say` and `end`, so decide answers `done: true`. `prompt_agent` records “Thanks.” and closes the conversation.
6. Once the conversation ends, `prompt_agent` reads the firing log from `GET /ext/triggers` and keeps it in the run’s metadata.
7. When no rule fires, `user-sim`’s `run()` answers instead: `prompt_agent` sends it `claudius`’s reply at `/a2a` and gets back `message` and `done`.

Parts of the scene:

- **The agent instance**: `claudius-4msj7n4d`, from Deploy your agent, at `http://localhost:42031`. It answers each turn as a new A2A task in the same context, and never sees the rules.
- **The SDK’s server**: `serve()` turns each A2A message at `/a2a` into one call to `run()`. `prompt_agent` sends every turn of the conversation here.
- **Your agent**: `ClaudiusRLMAgent`, unchanged. Each turn it answers with Claude and the `email` env’s tools, `email_search` and `send`.
- **register_agent_triggers**: A task step that posts its `triggers` to the agent it names, here `user-sim`. It fails if the card does not list `triggers/v1`, or if a rule names an env trigger that no `register_env_triggers` step registered.
- **prompt_agent**: Here with `max_conversation_turns` 3 and `user_agent_name` `user-sim`. It calls decide only when `user-sim`’s card lists triggers and a `register_agent_triggers` step registered rules on it, and never after the last turn.
- **The agent that plays the user**: An instance of `user-sim`, a small `AgentEnvAgent` written to play the user and deployed by the task’s `deploy_agent`. It lists `TRIGGERS_V1` in `extensions`, and its config adds `output_format`, which `ClaudiusRLMConfig` does not take, so `claudius` could not play this part.
- **The SDK’s trigger engine**: Declaring `TRIGGERS_V1` is all it takes: the SDK serves the three operations and evaluates the rules itself, in the instance’s memory. It calls no model.
- **register**: `POST /ext/triggers` with `triggers`, up to 256, each with an `id`, a `when` and `actions`. It adds to the rules already there, and an `id` registered before with a different rule gets a 400.
- **decide**: `POST /ext/triggers/decide` with the `turn`, `claudius`’s reply as `solver_message`, the conversation as `context_id`, and `env_triggers`, the state of each deployed env’s triggers. The answer has `parts`, `done` and `fired`.
- **state**: `GET /ext/triggers` returns each rule’s `id`, `when` type and `once`, and the firing log. `prompt_agent` keeps the log as `agent_trigger_state`; a read that fails is logged and skipped.
- **The confirm rule**: A `conversational` rule: it fires when `claudius`’s reply matches the regex `Send it\?`, and its `say` text becomes the next user message. `once` defaults to true, so it fires at most once per conversation.
- **The sent rule**: Two actions: `say` “Thanks.” and `end`, which sets `done`. Other `when` types match a turn number (`step`), an env trigger’s status (`env_trigger`), or combine rules (`all`, `any`).
- **The firing log**: The SDK logs each registration, each rule that fires with its turn and context, and each reset, when a turn is sent again and a context’s fired rules clear. It keeps the last 1,024 entries.
- **user-sim’s run()**: Called only on a turn when no rule fired, with `claudius`’s reply as the message. Before the first turn `prompt_agent` sets its `output_format` to JSON with `message` and `done`; it sends `message` as the next prompt, and `done: true` ends the conversation.
- **The conversation**: `prompt_agent` records each message in the conversation store, and sends decide the conversation’s id as `context_id`. The rules that fire each turn go in the run’s metadata as `agent_trigger_firings`.

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:

```json title="A register_agent_triggers step"
{"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, where `cmp` is `eq` or `gte`
* `conversational`, a `regex`, `equals` or `exists` test on the agent's reply
* `env_trigger`, the status of a trigger that a `register_env_triggers` step registered on an env
  (see [Triggers](https://www.agentenvframework.com/docs/environments/gateway-topology.md#triggers))
* `all` or `any` of other rules

Each rule fires at most once per conversation unless it sets `"once": false`.

## install

1. `claudius`, with the addition below, declares install/v1: the commands that install it into a running container, the placeholders they use and its A2A port.
2. `deploy_sandbox` starts a VM sandbox that exposes port 8000, and `run_docker_container` runs the task’s own container in it, `task-container`.
3. `install_agent` loads the agent’s image onto the VM only to read its card, and takes install/v1’s params from it. The image never serves.
4. It downloads the agent’s build context onto the VM and fills each placeholder the agent declares, shell-quoted: `{container}` becomes `task-container`.
5. It runs the commands on the VM, in order. They copy the build context into `task-container`, install `claudius` there and start it on port 8000.
6. `install_agent` waits for the card on the sandbox’s tunnel for 8000 and registers a new instance. `prompt_agent` sends it the task like any deployed agent.

Parts of the scene:

- **The agent’s card**: The card of `claudius` with the addition on this page. The `claudius` of Creating your own agent does not declare install/v1, so `install_agent` refuses it.
- **install/v1 on the card**: `enable(INSTALL_V1, ...)` puts whatever you pass into the extension’s `params` on the card. It has no endpoint: the agent serves nothing for it, and the framework only reads it.
- **install_commands**: Shell commands that `install_agent` runs on the sandbox’s VM, one script each, in order; one that fails fails the step. They run outside the container, so they reach it with `docker cp` and `docker exec`.
- **required_params**: The `{name}` placeholders the commands use, filled with Python’s `str.format`. `install_agent` knows `container`, `work_dir`, `agent_ctx_tar`, `a2a_port`, `agent_name`, `workspace_dir`, `litellm_api_key` and `litellm_base_url`; any other name, or a placeholder left undeclared, fails the step.
- **a2a_port**: The port the installed agent serves A2A on, unless the step sets its own `a2a_port`. The sandbox must expose it and the container publish it.
- **The filled placeholders**: Each value is shell-quoted before it goes in. `agent_ctx_tar` is `/tmp/install-agent-assistant/agent-ctx.tar.gz`, named after the step’s `agent_name`, `assistant`, and the model endpoint and key come from `[model]`, or the run’s own key.
- **deploy_sandbox**: A task step that starts a bare sandbox, here `task-host`. `install_agent` needs `sandbox_mode: "vm"`, and `exposed_ports` must include the agent’s port.
- **run_docker_container**: A task step that builds a Dockerfile on a VM sandbox and runs the container in the background, under its `container_name` and publishing its `ports`.
- **install_agent**: A task step that installs an agent into a container the task already runs, where `deploy_agent` starts the agent’s image in a sandbox of its own. Without `container_name`, it runs the card’s `install_commands_host` on the VM instead.
- **prompt_agent**: A task step that sends the prompt to the agent it names, `assistant`, as an A2A message at `/a2a`, whether a step deployed or installed it.
- **The sandbox**: `task-host`, a VM-mode sandbox: it runs Docker, so steps build and run containers in it. `install_agent` works only on these.
- **The agent’s image**: The image of the later version of `claudius` with the addition, loaded onto the VM to read the card: `install_agent` runs it once, with `--rm` and `python3`, to print `AGENT_CARD` from `a2a_server.py` in `/app`. Nothing is served from it.
- **The build context**: What `a2a-agent put` stored with the image: the Dockerfile and the files it copies. `install_agent` downloads it to the VM, where the commands pick it up.
- **The task’s container**: `task-container`, which `run_docker_container` started from the task’s own image. The container keeps running on its own command, here `sleep infinity`, until the install fills it.
- **What the commands put there**: The build context, unpacked into `/opt/claudius`, and a virtual environment with `agentenv-framework-protocol[agent]`, `mcp` and `anthropic`, the packages the Dockerfile installs.
- **The tunnel for 8000**: The sandbox’s URL for port 8000. `install_agent` polls `/.well-known/agent.json` there for up to five minutes, and the task reaches the agent at that URL.
- **The installed agent**: `ClaudiusRLMAgent`, started by the last command inside `task-container`, where `serve()` listens on `A2A_PORT`. `install_agent` posts no envs through mcp-config, so it works with what the container holds.
- **The agent instance**: `claudius-fjssqaau`, a later version of `claudius` with the addition, which the task’s `install_agent` registers in the instance store once its card answers, as a deploy does. In this task it is `assistant`.

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:

```python title="claudius/agent.py (additions)"
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.