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
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.
An env type subclasses Env, names itself with a type ClassVar, and turns its stored document
back into an object with from_dict:
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 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:
[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 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:
agent-env plugin add ./agentenv-openciv-plugin
agent-env plugin list
agent-env plugin checkPACKAGE 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 effectPut and Get It
The CLI has no put for a custom env type, so you register the VM image and the env from Python:
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()])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:
[
{"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:
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 outTask 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.
Last updated on