Skip to content
AgentEnv Framework

Environment Provider Plugins

Write a topology of your own that deploys the openciv3_client env onto a GPU VM and returns its record

An environment provider decides what an env instance is made of and returns the record agents connect through. The OpenCiv3 plugin doesn't ship one yet; this page writes openciv3_client_vm, which boots the openciv3_client env from Environment Plugins on a GPU VM, with the client on its display.

When to Write One

The built-in gateway and server topologies run MCP servers in containers, with or without a gateway in front, as Choose the environment's topology shows. The client env is a VM image that has to boot, start the client and serve the game's tools, which neither built-in does. When the env's instances need a shape of their own like this, you write a provider, and the env names it in env_provider_type.

The Contract

A provider has a type, an async deploy(env, sandbox_provider, **options) that returns the env's record unregistered, and a close() that tears down everything deploy created. build_env_provider(type) builds it by calling the class with no arguments, so a provider keeps what it creates on the instance:

src/agentenv_openciv3/client_provider.py
class ClientVmProvider(EnvironmentProvider):
    type: ClassVar[str] = "openciv3_client_vm"

    def __init__(self) -> None:
        self._vms: list[VmSandbox] = []

    async def deploy(self, env: Env, sandbox_provider: SandboxProvider, *, ttl_seconds: int = 10800,
                     disk_size_gb: float = 10, cpu: float | None = None, memory_mb: int | None = None,
                     priority: int | None = None, attribution: Attribution | None = None,
                     **options: Any) -> DeployedSandboxEnv:
        if not isinstance(env, OpenCiv3ClientEnv):
            raise TypeError(f"env_provider_type '{self.type}' deploys a {OpenCiv3ClientEnv.type} env, not a {env.type} env")
        image = env.vm_image
        vm = await sandbox_provider.create_vm(
            image=image.image, exposed_ports=[MCP_PORT], setup_for_gateway=False, timeout=ttl_seconds,
            cpu=cpu or image.cpu or 2.0, memory=memory_mb or image.memory_mb or 8192,
            disk_size_gb=max(disk_size_gb, image.disk_size_gb or 0), priority=priority, attribution=attribution,
        )
        self._vms.append(vm)
        await vm.write_host_file(_client_env(env).encode(), CLIENT_ENV_PATH)
        await vm.exec_script(f"systemctl start {CLIENT_UNIT}")
        url = vm.tunnel_urls[MCP_PORT]
        boot_timeout = float(settings("agentenv-openciv3").get("boot_timeout_seconds", 300))
        card = await _wait_for_card(url, env.environment_name, boot_timeout)
        if missing := [name for name in GAME_TOOLS if protocol_v1.find_tool(card, name) is None]:
            raise RuntimeError(f"'{env.environment_name}' serves no {', '.join(missing)} tool; "
                               f"the game serves {', '.join(GAME_TOOLS)}")
        return DeployedSandboxEnv(
            env_id=env.id,
            env_version=env.version,
            env_provider_type=self.type,
            environment_card_url=f"{url}{WELL_KNOWN_PATH}",
            environment_card=card,
            environment_card_read_at_utc=datetime.now(timezone.utc).isoformat(),
            sandbox_id=vm.sandbox_id,
            sandbox_type=vm.type,
        )

    async def close(self) -> None:
        while self._vms:
            await self._vms.pop().terminate()

deploy_env always passes ttl_seconds, disk_size_gb, gateway_mode, cpu, memory_mb, priority and attribution, plus env_state_type and env_state_instance_id when they are set. The provider names the options it uses and takes the rest in **options: on the built-in env path, a deploy without **options is refused any option it doesn't name that is set away from the env's default. The abstract method is declared as deploy(self, env, sandbox_provider), so a type checker flags keyword calls to it, which is why OpenCiv3ClientEnv.deploy forwards its options as **options.

Deploying the Client

The image's openciv3-client unit reads /etc/openciv3-client/env (the env's environment_name, screen size and view), starts the env's MCP server on port 18765 and the client on the display. A VM reports running before the game is up, so _wait_for_card polls the card every two seconds until boot_timeout, and refuses a card named other than the env's environment_name. The VM is a gpu_vm sandbox, from Sandbox Provider Plugins.

Refusing an Env It Doesn't Host

The isinstance check runs before anything is created, so a stock env pointed at this type fails with nothing to clean up. Handed the openciv3 MCPServerEnv from agent-env openciv3 setup, the provider raises and the VM service sees no request:

Output
unsupported env: TypeError env_provider_type 'openciv3_client_vm' deploys a openciv3_client env, not a mcp_server env

What the Record Carries

The record is a DeployedSandboxEnv because it names the VM in sandbox_id and sandbox_type, which is how teardown_sandboxes finds and terminates it. Its env_provider_type is the provider's type, and the card and its URL come together. mcp_url and mcp_server_name are derived from the card, and metadata belongs to the registry and the deploy step, so the provider sets none of them.

Output (trimmed)
record class: DeployedSandboxEnv
{ "env_id": "openciv3-live", "env_version": 1, "env_provider_type": "openciv3_client_vm",
  "environment_card_url": "http://127.0.0.1:51557/.well-known/agent-env.json",
  "environment_card": {"name": "openciv3", "protocolVersion": "1.0", ...},
  "mcp_url": "http://127.0.0.1:51557/mcp", "mcp_server_name": "openciv3", "metadata": null,
  "instance_id": "openciv3-live-mkjg2vgq", ...
  "sandbox_id": "vm-11aae0b9", "sandbox_type": "gpu_vm", "sandbox_ids": {} }
core's check_plugin_record(env, record): passes

What Core Checks

When a stock MCPServerEnv, WebsiteEnv or MultiEnv deploys through a plugin's provider, core runs check_plugin_record on the record. It requires the env's env_provider_type, the card and its URL together, an MCP URL, a card named the env's environment_name or listing a child env of that name, and child url values that are paths; for a MultiEnv, every child listed and mcp_server_name equal to the env's name when it has one.

OpenCiv3ClientEnv is a custom env that registers the record itself with register_env_instance, which checks only that the record loads back as its own class. A DeployedSandboxEnv subclass, for example, is refused with TypeError: a X record loads back as Y, dropping its own fields; deploy() must return a Y, while a failed store write is only logged and leaves the record without an instance_id. This record passes check_plugin_record too, as the output above shows, and the provider's own tool check covers what the game must serve.

Provider Settings

Environment providers have no config table, so this one reads its own settings from [plugins.agentenv-openciv3] with agent_env.plugins.settings("agentenv-openciv3"). It calls settings inside deploy, since the function reads the config each time, and converts the value with float(...), because an env: reference arrives as a string.

.agentenv/config.toml
[plugins.agentenv-openciv3]
boot_timeout_seconds = "env:OPENCIV3_BOOT_TIMEOUT?300"

With OPENCIV3_BOOT_TIMEOUT=3 and an image whose unit never starts, the deploy fails and OpenCiv3ClientEnv.deploy calls close(), which terminates the VM:

Output
boot timeout: 'openciv3' served no env card at http://127.0.0.1:60525/.well-known/agent-env.json in 3.0s: ConnectError('All connection attempts failed')

Registering the Provider

The provider installs through an agent_env.env_providers entry point whose name must equal its type, which records carry as env_provider_type. A class whose type differs, or that leaves deploy or close unimplemented, is refused at registration.

pyproject.toml
[project.entry-points."agent_env.env_providers"]
openciv3_client_vm = "agentenv_openciv3.client_provider:ClientVmProvider"
Terminal (trimmed)
$ agent-env plugin show agentenv-openciv3
  environment provider  openciv3_client_vm  agentenv_openciv3.client_provider:ClientVmProvider  active
$ agent-env plugin check
ok: 2 plugin package(s), 9 contribution(s), all in effect

How an Env Picks the Provider

An env records its provider in env_provider_type. OpenCiv3ClientEnv defaults it to openciv3_client_vm, lists the types it accepts in env_provider_types, and builds the provider in its own deploy:

src/agentenv_openciv3/client_env.py
    env_provider_types: ClassVar[tuple[str, ...]] = ("openciv3_client_vm",)

    async def deploy(self, *, sandbox_type: Optional[str] = None, **options: Any) -> DeployedEnv:
        ...
        sandbox_provider = build_sandbox_provider(spec)
        provider = build_env_provider(self.env_provider_type)
        try:
            deployed = await provider.deploy(self, sandbox_provider, **options)
            deployed = register_env_instance(deployed, options["ttl_seconds"])
        except BaseException:
            await provider.close()
            raise

A stock env picks a provider with --env-provider-type on put, as on the topology page, but this one hosts only openciv3_client and refuses the rest.

Last updated on

Ask a question · Report an issue

On this page