# Environment Plugins (https://www.agentenvframework.com/docs/plugins/environment-plugins)

> Add an env type from an installed package, using OpenCiv3's client on a GPU VM as the example

## Why a New Env Type

`agent-env openciv3 setup` registers the game as a stock `MCPServerEnv`, which runs a Docker image as a
container, behind a gateway or on its own, as [Choose the environment's topology](https://www.agentenvframework.com/docs/environments/topology.md)
shows. Drawing every turn live, not on the CPU afterwards, needs a GPU VM image, a screen size and a
view. The plugin doesn't ship that yet; this page writes `openciv3_client`, which stores those fields
and leaves the deploy to an environment provider.

[![The real OpenCiv3 client's view of a three-agent game from Rome's side, turn by turn](https://www.agentenvframework.com/plugins/openciv3-client-poster.jpg)](https://www.agentenvframework.com/plugins/openciv3-client.mp4)

An env type subclasses `Env`, names itself with a `type` ClassVar, and turns its stored document
back into an object with `from_dict`:

```python title="src/agentenv_openciv3/client_env.py"
from __future__ import annotations

from typing import Any, ClassVar, Optional, Self

from agent_env.artifact.artifacts.vm_image import VMImageArtifact
from agent_env.env.env import DeployedEnv, Env
from agent_env.env.store import register_env_instance
from agent_env.providers.env_providers.env_provider import EnvironmentProvider, build_env_provider
from agent_env.providers.sandbox_providers.sandbox_provider import build_sandbox_provider


class OpenCiv3ClientEnv(Env):
    type: ClassVar[str] = "openciv3_client"
    description: ClassVar[str] = "OpenCiv3 on a GPU VM, the real client drawing every turn"
    env_provider_types: ClassVar[tuple[str, ...]] = ("openciv3_client_vm",)

    def __init__(self, id: str, version: Optional[int], vm_image: VMImageArtifact, environment_name: str, *,
                 screen_width: int = 1920, screen_height: int = 1080, view: str = "spectator",
                 env_provider_type: str = "openciv3_client_vm", metadata: Optional[dict[str, Any]] = None):
        super().__init__(id, version, metadata=metadata)
        if not isinstance(vm_image, VMImageArtifact):
            raise TypeError(f"vm_image must be a VMImageArtifact, not a {type(vm_image).__name__}")
        if not environment_name:
            raise ValueError("environment_name cannot be empty")
        if screen_width <= 0 or screen_height <= 0:
            raise ValueError(f"screen size must be positive, got {screen_width}x{screen_height}")
        if view not in ("spectator", "agent"):
            raise ValueError(f"view must be 'spectator' or 'agent', got {view!r}")
        self.vm_image = vm_image
        self.environment_name = environment_name
        self.screen_width = screen_width
        self.screen_height = screen_height
        self.view = view
        self.env_provider_type = env_provider_type
        self._provider: Optional[EnvironmentProvider] = None
        self._deployed: Optional[DeployedEnv] = None

    def to_dict(self) -> dict[str, Any]:
        return {
            **super().to_dict(),
            "vm_image": {"id": self.vm_image.id, "version": self.vm_image.version, "type": self.vm_image.type},
            "environment_name": self.environment_name,
            "screen_width": self.screen_width,
            "screen_height": self.screen_height,
            "view": self.view,
            "env_provider_type": self.env_provider_type,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Self:
        image = data["vm_image"]
        return cls(
            id=data["id"],
            version=data.get("version"),
            vm_image=VMImageArtifact.get(image["id"], version=image["version"]),
            environment_name=data["environment_name"],
            screen_width=data["screen_width"],
            screen_height=data["screen_height"],
            view=data["view"],
            env_provider_type=data["env_provider_type"],
            metadata=data.get("metadata"),
        )
```

`type` is the identity written into every stored document, and the registry reads each document
back through it. The inherited `to_dict` supplies `id`, `type`, `version` and `metadata`, and the
document keeps the VM image as a reference to one version, which `from_dict` loads back.

The checks run in `__init__`, which `put` calls before it writes, so a bad env is never stored:
`view="god"` fails with `ValueError: view must be 'spectator' or 'agent', got 'god'`.

Its `deploy` hands the work to the `openciv3_client_vm` provider, which
[Environment Provider Plugins](https://www.agentenvframework.com/docs/plugins/environment-provider-plugins.md) writes.

## Declare the Entry Point

The package registers the class under the `agent_env.envs` entry-point group, and the entry-point
name must equal the class's `type`:

```toml title="pyproject.toml"
[project]
name = "agentenv-openciv3"
version = "0.1.0"
dependencies = ["agentenv-framework>=0.9.1260", "agentenv-framework-protocol>=0.1.275,<0.2", "mcp>=1.25,<2"]

[project.entry-points."agent_env.envs"]
openciv3_client = "agentenv_openciv3.client_env:OpenCiv3ClientEnv"
```

The same file declares the package's other contributions, which [Plugins](https://www.agentenvframework.com/docs/plugins.md) lists. A class
that leaves `from_dict` unimplemented is reported as `failed` with `invalid-plugin`, and a plugin cannot
replace a built-in type.

## Install and Check It

A plugin must be installed into the same environment as agent-env. `agent-env plugin add` runs that
environment's own installer and then checks the new package; installing it with that installer
directly, as `uv tool install agentenv-framework --with-editable ./agentenv-openciv-plugin`, works too:

```bash title="Terminal"
agent-env plugin add ./agentenv-openciv-plugin
agent-env plugin list
agent-env plugin check
```

```text title="Output (excerpt)"
PACKAGE             VERSION   PROVIDES                                                                                  STATUS
agentenv-framework  0.9.1267  bundle hello                                                                              ok
agentenv-openciv3   0.1.0     1 env, 3 task steps, 1 sandbox provider, 1 environment provider, 1 CLI command, 1 bundle  ok

ok: 2 plugin package(s), 9 contribution(s), all in effect
```

## Put and Get It

The CLI has no `put` for a custom env type, so you register the VM image and the env from Python:

```python title="put_client.py"
from agent_env.artifact.artifacts.vm_image import VMImageArtifact
from agent_env.env.env import Env
from agentenv_openciv3.client_env import OpenCiv3ClientEnv

image = VMImageArtifact.put(
    id="openciv3-client-gpu", description="The OpenCiv3 env and client on a GPU desktop",
    image_name="registry.test/openciv3/client-gpu:1", disk_size_gb=40, cpu=8, memory_mb=16384, sandbox_type="gpu_vm",
)
env = OpenCiv3ClientEnv.put(
    id="openciv3-live", vm_image=image, environment_name="openciv3",
    screen_width=1920, screen_height=1080, view="spectator",
)
loaded = Env.get("openciv3-live")
print("Env.get ->", type(loaded).__name__, "round-trips:", loaded.to_dict() == env.to_dict())
print("query().type('openciv3_client') ->", [(e.id, e.version) for e in Env.query().type("openciv3_client").execute()])
```

```text title="Output"
Env.get -> OpenCiv3ClientEnv round-trips: True
query().type('openciv3_client') -> [('openciv3-live', 1)]
```

`Env.get` returns an `OpenCiv3ClientEnv` because the registry maps the stored `type` to the plugin's
class, and `from_dict` loads version 1 of the VM image the document pins.

## Use It in a Task

A `deploy_env` step deploys the env by its id, and later steps find the instance by the same id. The
`openciv3_match` and `save_env_recording` steps come from [Task Step
Plugins](https://www.agentenvframework.com/docs/plugins/task-step-plugins.md):

```json title="tasks/live-match.json"
[
  {"id": "deploy", "type": "deploy_env", "env_id": "openciv3-live"},
  {"id": "agent", "type": "deploy_agent", "agent_name": "opus", "a2a_agent_id": "openciv3-claude",
   "env_ids": ["openciv3-live"], "depends_on": ["deploy"]},
  {"id": "match", "type": "openciv3_match", "env_id": "openciv3-live", "turns": 10, "civs": {"opus": "Rome"},
   "depends_on": ["agent"]},
  {"id": "play", "type": "prompt_agent", "agent_name": "opus", "prompt": "Play Rome.",
   "depends_on": ["match"]},
  {"id": "recording", "type": "save_env_recording", "env_id": "openciv3-live", "depends_on": ["play"]},
  {"id": "teardown", "type": "teardown_sandboxes", "env_ids": ["openciv3-live"], "depends_on": ["recording"]}
]
```

`deploy_env` passes its fields to `deploy`, and `sandbox_type` only when the step or `--env-sandbox`
sets one, so this run uses the image's `gpu_vm`. `teardown_sandboxes` terminates the VM that the
instance's record names:

```bash title="Terminal"
agent-env task create --id openciv3-live-match --project-id demo tasks/live-match.json
agent-env task run --id openciv3-live-match --project-id demo --output-dir out
```

```text title="Output (task run, excerpt)"
Task instance: openciv3-live-match-dx6mk15o
Completed step [1/6]: deploy_env (id=deploy) [4.4s]
deployed-env:
  instance_id: openciv3-live-mkjg2vgq
  env_id: openciv3-live
  env_version: 1
  mcp_url: http://127.0.0.1:51557/mcp
  sandbox_id: vm-11aae0b9
  sandbox_type: gpu_vm
Completed step [4/6]: prompt_agent (id=play) [94.3s]
Completed step [5/6]: save_env_recording (id=recording) [4.1s]
Completed step [6/6]: teardown_sandboxes (id=teardown) [0.2s]
Task completed!
```

`agent-env env deploy --id openciv3-live` deploys it on its own too. It prints
`Sandbox backend: config default` even though `deploy` picked `gpu_vm`, and it never closes the instance.