# Environment Provider Plugins (https://www.agentenvframework.com/docs/plugins/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](https://www.agentenvframework.com/docs/plugins/environment-plugins.md)
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](https://www.agentenvframework.com/docs/environments/topology.md) 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:

```python title="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](https://www.agentenvframework.com/docs/plugins/sandbox-provider-plugins.md).

[![The live view the OpenCiv3 env serves at /live on the same port while a game plays: the map, the scores, the score chart and each turn's events](https://www.agentenvframework.com/plugins/openciv3-live-view-poster.jpg)](https://www.agentenvframework.com/plugins/openciv3-live-view.mp4)

## 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:

```text title="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.

```text title="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.

```toml title=".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:

```text title="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.

```toml title="pyproject.toml"
[project.entry-points."agent_env.env_providers"]
openciv3_client_vm = "agentenv_openciv3.client_provider:ClientVmProvider"
```

```text title="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`:

```python title="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.