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:
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:
unsupported env: TypeError env_provider_type 'openciv3_client_vm' deploys a openciv3_client env, not a mcp_server envWhat 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.
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): passesWhat 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.
[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:
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.
[project.entry-points."agent_env.env_providers"]
openciv3_client_vm = "agentenv_openciv3.client_provider:ClientVmProvider"$ 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 effectHow 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:
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()
raiseA 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