This guide provisions the Windows Fleet configuration from Cua's Terraform
how-to guide, then uses the PyPI cua-fleet package to claim a sandbox,
initialize its computer-server MCP endpoint, and release the claim.
The Terraform resource owns the Fleet pool. The Python program only borrows a sandbox from that existing pool, so it never deletes the pool or namespace.
- Terraform (or OpenTofu) and Go to install the temporary local
trycua/fleetsTerraform provider. - A run.cua.ai OAuth user key. Use a user key, not a pool key, because claims need an identity that is allowed to create Kubernetes resources.
- Python 3.10+ and
uv.
The
trycua/fleetsprovider is temporarily installed from a local filesystem mirror. Follow the provider README's temporary-local-installation instructions before runningterraform init.
Set the Terraform CLI configuration file created by the provider installation:
export TF_CLI_CONFIG_FILE="$HOME/.terraformrc-fleets-local"Use the run.cua.ai endpoint and OAuth user-key credentials. Keep secrets out of source control and Terraform variables.
export CYCLOPS_ENDPOINT='https://run.cua.ai'
export CYCLOPS_CLIENT_ID='ukey-...'
export CYCLOPS_CLIENT_SECRET='...'
export CYCLOPS_TOKEN_URL='https://auth.cua.ai/realms/cyclops-cs/protocol/openid-connect/token'The Python client below uses the same OAuth values. It accepts the CUA_*
variables used by the original SDK example, with CYCLOPS_* as fallbacks.
export CUA_BASE_URL="$CYCLOPS_ENDPOINT"
export CUA_CLIENT_ID="$CYCLOPS_CLIENT_ID"
export CUA_CLIENT_SECRET="$CYCLOPS_CLIENT_SECRET"
export CUA_TOKEN_URL="$CYCLOPS_TOKEN_URL"Create an empty directory, then save the following as main.tf. The Windows
computer-server image requires UEFI, which is why firmware = "efi" is
required. The readiness probe and computer-server service both use port
8000.
terraform {
required_providers {
fleets = {
source = "trycua/fleets"
version = "1.0.0"
}
}
}
provider "fleets" {
endpoint = "https://run.cua.ai"
}
resource "fleets_pool" "windows" {
name = "windows-fleet"
replicas = 0
cpu_cores = 4
memory = "4Gi"
container_disk_image = "296062593712.dkr.ecr.us-west-2.amazonaws.com/cua-server-windows:latest"
runtime = "kubevirt"
firmware = "efi"
readiness_probe_json = jsonencode({
tcpSocket = { port = 8000 }
initialDelaySeconds = 60
periodSeconds = 5
timeoutSeconds = 3
failureThreshold = 120
})
service {
name = "computer-server"
target_port = 8000
protocol = "TCP"
}
autoscaling {
min_pool_size = 0
initial_pool_size = 1
max_pool_size = 5
}
}
output "windows_fleet" {
value = {
name = fleets_pool.windows.name
namespace = fleets_pool.windows.namespace
phase = fleets_pool.windows.phase
}
}Initialize and apply it:
rm -rf .terraform .terraform.lock.hcl
terraform init
terraform fmt -check
terraform validate
terraform plan
terraform apply
terraform outputSet the pool name for the Python program. The Terraform provider uses the
pool's name as its namespace, so the defaults are both windows-fleet.
export CUA_POOL='windows-fleet'
export CYCLOPS_NAMESPACE="$CUA_POOL"Save this as live_app_controlled.py:
#!/usr/bin/env python3
import asyncio
import json
import os
import time
import urllib.error
import urllib.request
from cyclops_sdk import (
CreateClaimRequest,
CyclopsClient,
CyclopsConfiguration,
CyclopsCredentials,
HttpClient,
HttpHeader,
HttpRequest,
HttpResponse,
)
class UrlLibHttpClient(HttpClient):
async def execute(self, request: HttpRequest) -> HttpResponse:
return await asyncio.to_thread(self._execute, request)
def _execute(self, request: HttpRequest) -> HttpResponse:
native = urllib.request.Request(
request.url,
data=request.body,
method=request.method,
headers={header.name: header.value for header in request.headers},
)
try:
with urllib.request.urlopen(native, timeout=60) as response:
return HttpResponse(
status=response.status,
headers=[
HttpHeader(name=name, value=value)
for name, value in response.headers.items()
],
body=response.read(),
)
except urllib.error.HTTPError as error:
return HttpResponse(
status=error.code,
headers=[
HttpHeader(name=name, value=value)
for name, value in error.headers.items()
],
body=error.read(),
)
async def initialize_mcp(client: CyclopsClient, sandbox) -> int:
body = json.dumps(
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {
"name": "cua-fleet-windows-live-control",
"version": "0.1.0",
},
},
},
separators=(",", ":"),
).encode()
deadline = time.monotonic() + 300
while True:
response = await client.service_request(
sandbox,
"computer-server",
"/mcp",
HttpRequest(
method="POST",
url="https://ignored.invalid/mcp",
headers=[
HttpHeader(
name="accept",
value="application/json, text/event-stream",
),
HttpHeader(name="content-type", value="application/json"),
],
body=body,
),
)
if 200 <= response.status < 300:
return response.status
if response.status in (502, 503, 504) and time.monotonic() < deadline:
print(f"computer-server is starting: HTTP {response.status}")
await asyncio.sleep(5)
continue
raise RuntimeError(
f"MCP initialize failed with HTTP {response.status}: {response.body!r}"
)
async def main() -> None:
client_id = os.environ["CUA_CLIENT_ID"]
client_secret = os.environ["CUA_CLIENT_SECRET"]
base_url = os.environ["CUA_BASE_URL"]
token_url = os.environ["CUA_TOKEN_URL"]
namespace = os.environ["CYCLOPS_NAMESPACE"]
pool_name = os.environ["CUA_POOL"]
client = CyclopsClient.connect(
CyclopsConfiguration(
base_url=base_url,
token_url=token_url,
credentials=CyclopsCredentials(client_id, client_secret),
pool_poll_interval_ms=5000,
pool_poll_limit=120,
claim_poll_interval_ms=5000,
claim_poll_limit=120,
),
UrlLibHttpClient(),
)
pools = await client.list_pools(namespace)
pool = next((candidate for candidate in pools if candidate.metadata.name == pool_name), None)
if pool is None:
available = ", ".join(candidate.metadata.name for candidate in pools) or "(none)"
raise RuntimeError(
f"Pool {pool_name!r} was not found in namespace {namespace!r}; "
f"available: {available}"
)
claim = None
try:
claim = await client.create_claim(CreateClaimRequest(pool=pool, spec=None))
sandbox = await client.wait_claim(claim)
mcp_status = await initialize_mcp(client, sandbox)
print(
json.dumps(
{
"namespace": namespace,
"pool": pool.metadata.name,
"claim": claim.metadata.name,
"sandbox": sandbox.name,
"mcp_status": mcp_status,
},
sort_keys=True,
)
)
finally:
if claim is not None:
await client.delete_claim(claim)
asyncio.run(main())Run it without creating a virtual environment or a pyproject.toml:
uv run --with cua-fleet==0.0.5 python live_app_controlled.pyExpected output is a JSON object containing the pool, temporary claim,
assigned sandbox, and an HTTP 2xx MCP status. The finally block releases the
claim even if initialization fails; the Terraform Fleet remains ready for the
next claim.
Destroy the Terraform-managed Fleet when you are finished. This removes the pool and its same-named namespace.
terraform destroy- A
403during pool creation generally means the image reference or image-pull-secret policy is not allowed. The provider defaultsimage_pull_secrettoecr-credentials. - A Windows VM that does not boot normally has an incorrect firmware setting;
the Windows image requires
firmware = "efi". - If the claim binds but MCP returns
502,503, or504, keep polling. The script waits up to five minutes for the guest'scomputer-serverto finish starting. - The pool name must be a lowercase DNS label with at most 63 characters. It also determines the namespace.
- Protect shell history, Terraform state, client secrets, and access tokens.