diff --git a/.gitignore b/.gitignore
index 619209d..3632361 100644
--- a/.gitignore
+++ b/.gitignore
@@ -181,3 +181,4 @@ CODE_EXECUTION.md
.code-review-graph/
.claude
.mcp.json
+AGENTS.md
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index 53b599d..0000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,56 +0,0 @@
-# AGENTS.md
-
-## Project
-
-Wrenn Python SDK — a client library for the Wrenn microVM platform. e2b drop-in replacement.
-Package name: `wrenn`. Python 3.13+, managed with [uv](https://docs.astral.sh/uv/).
-
-## Commands
-
-```bash
-uv sync # install deps
-make lint # ruff check + format check (no auto-fix)
-make test # unit tests only (tests/test_client.py)
-make test-integration # all tests including integration (needs live server)
-make generate # regenerate models from OpenAPI spec (fetches from remote)
-make check # lint + unit test
-```
-
-- `make test` only runs `tests/test_client.py`, not all unit tests. To run a specific test file: `uv run pytest tests/test_capsule_features.py -v`
-- No typecheck step in Makefile or CI. `mypy` is a dev dependency but not wired up — do not assume it runs.
-
-## Architecture
-
-- `src/wrenn/` — the library package
- - `capsule.py` / `async_capsule.py` — high-level `Capsule` / `AsyncCapsule` (main user-facing classes)
- - `client.py` — low-level `WrennClient` / `AsyncWrennClient`
- - `commands.py` — command execution and streaming
- - `files.py` — filesystem operations
- - `pty.py` — interactive terminal (PTY) over WebSocket
- - `exceptions.py` — typed error hierarchy (`WrennError` base)
- - `models/_generated.py` — **auto-generated** from OpenAPI spec via `datamodel-codegen` (never edit directly; run `make generate`)
- - `sandbox.py` — deprecated `Sandbox` alias for `Capsule`
- - `code_interpreter/` — specialized capsule for stateful Jupyter kernel execution
-- `tests/` — unit tests use `respx` to mock `httpx`; integration tests are in `tests/integration/`
-- `api/openapi.yaml` — downloaded OpenAPI spec used for code generation
-
-## Key Conventions
-
-- Generated code lives in `src/wrenn/models/_generated.py`. Never edit it. Run `make generate` to update.
-- `Sandbox` is a deprecated alias for `Capsule`. New code should use `Capsule` / `AsyncCapsule`.
-- Dual sync/async API: every major class has an `Async` counterpart.
-- Uses `httpx` for HTTP, `httpx-ws` for WebSockets, `pydantic` for models.
-- `__init__.py` uses `__getattr__` for lazy deprecated aliases (`Sandbox`, `WrennHostHasSandboxesError`).
-
-## Testing
-
-- Unit tests mock HTTP via `respx` (httpx mocking library).
-- Integration tests require env vars: `WRENN_API_KEY` (or `WRENN_TOKEN`), optionally `WRENN_BASE_URL`.
-- Integration test fixtures in `tests/integration/conftest.py` create real capsules and clean them up.
-- `pytest` marker: `@pytest.mark.integration` for tests needing a live server.
-
-## CI
-
-Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`:
-1. `make lint`
-2. `make test` (unit tests only — integration tests are not in CI)
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 8d3861c..dfc5c75 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -1,8 +1,8 @@
openapi: "3.1.0"
info:
title: Wrenn API
- description: MicroVM-based code execution platform API.
- version: "0.1.3"
+ description: AI agent execution platform API.
+ version: "0.2.0"
servers:
- url: http://localhost:8080
@@ -866,8 +866,8 @@ paths:
schema:
$ref: "#/components/schemas/CreateCapsuleRequest"
responses:
- "201":
- description: Capsule created
+ "202":
+ description: Capsule creation initiated (status will be "starting")
content:
application/json:
schema:
@@ -988,8 +988,8 @@ paths:
security:
- apiKeyAuth: []
responses:
- "204":
- description: Capsule destroyed
+ "202":
+ description: Capsule destruction initiated
/v1/capsules/{id}/exec:
parameters:
@@ -1260,8 +1260,8 @@ paths:
destroys all running resources. The capsule exists only as files on
disk and can be resumed later.
responses:
- "200":
- description: Capsule paused (snapshot taken, resources released)
+ "202":
+ description: Capsule pause initiated (status will be "pausing")
content:
application/json:
schema:
@@ -1292,8 +1292,8 @@ paths:
memory loading. Boots a fresh Firecracker process, sets up a new
network slot, and waits for envd to become ready.
responses:
- "200":
- description: Capsule resumed (new VM booted from snapshot)
+ "202":
+ description: Capsule resume initiated (status will be "resuming")
content:
application/json:
schema:
@@ -2035,6 +2035,51 @@ paths:
schema:
$ref: "#/components/schemas/Error"
+ /v1/hosts/sandbox-events:
+ post:
+ summary: Sandbox lifecycle event callback
+ operationId: sandboxEventCallback
+ tags: [hosts]
+ security:
+ - hostTokenAuth: []
+ description: |
+ Receives autonomous lifecycle events from host agents (e.g. auto-pause
+ from the TTL reaper). The event is published to an internal Redis stream
+ for the control plane's event consumer to process.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [event, sandbox_id, host_id]
+ properties:
+ event:
+ type: string
+ enum: [sandbox.auto_paused]
+ sandbox_id:
+ type: string
+ host_id:
+ type: string
+ timestamp:
+ type: integer
+ format: int64
+ responses:
+ "204":
+ description: Event accepted
+ "400":
+ description: Invalid request
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+ "403":
+ description: Host ID mismatch
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+
/v1/hosts/auth/refresh:
post:
summary: Refresh host JWT
@@ -2346,6 +2391,54 @@ paths:
schema:
$ref: "#/components/schemas/Error"
+ /v1/admin/users/{id}/admin:
+ put:
+ summary: Grant or revoke platform admin
+ operationId: setUserAdmin
+ tags: [admin]
+ description: |
+ Sets the platform admin flag on a user. Cannot remove the last admin.
+ Requires platform admin access (JWT + is_admin).
+ The target user's JWT is not re-issued — their frontend will reflect the
+ change on next login or team switch.
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ example: "usr-a1b2c3d4"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [admin]
+ properties:
+ admin:
+ type: boolean
+ description: true to grant admin, false to revoke.
+ responses:
+ "204":
+ description: Admin status updated
+ "400":
+ $ref: "#/components/responses/BadRequest"
+ "403":
+ description: Caller is not a platform admin
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+ "404":
+ description: User not found
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
+
components:
securitySchemes:
apiKeyAuth:
@@ -2544,7 +2637,7 @@ components:
type: string
status:
type: string
- enum: [pending, starting, running, paused, hibernated, stopped, missing, error]
+ enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error]
template:
type: string
vcpus:
diff --git a/docs/reference.md b/docs/reference.md
index 7e32f6c..9a406df 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -1964,15 +1964,17 @@ inactivity TTL is set.
#### wait\_ready
```python
-async def wait_ready(timeout: float = 30, interval: float = 0.5) -> None
+async def wait_ready(timeout: float = 30) -> None
```
Await until the capsule status is ``running``.
+Polling interval adapts to the current transient status:
+0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
+
**Arguments**:
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
-- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``.
**Raises**:
@@ -2534,15 +2536,17 @@ inactivity TTL is set.
#### wait\_ready
```python
-def wait_ready(timeout: float = 30, interval: float = 0.5) -> None
+def wait_ready(timeout: float = 30) -> None
```
Block until the capsule status is ``running``.
+Polling interval adapts to the current transient status:
+0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
+
**Arguments**:
- `timeout` _float_ - Maximum seconds to wait. Defaults to ``30``.
-- `interval` _float_ - Polling interval in seconds. Defaults to ``0.5``.
**Raises**:
@@ -2700,17 +2704,6 @@ Create a snapshot template from this capsule's current state.
# wrenn.\_config
-
-
-## ConnectionConfig Objects
-
-```python
-@dataclass(frozen=True)
-class ConnectionConfig()
-```
-
-Resolved credentials and base URL for Wrenn API calls.
-
# wrenn.\_git.\_auth
diff --git a/src/wrenn/async_capsule.py b/src/wrenn/async_capsule.py
index 1d72408..44a9e84 100644
--- a/src/wrenn/async_capsule.py
+++ b/src/wrenn/async_capsule.py
@@ -1,8 +1,8 @@
from __future__ import annotations
import asyncio
-import logging
import builtins
+import logging
import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@@ -140,14 +140,19 @@ class AsyncCapsule:
info = await client.capsules.get(capsule_id)
if info.status == Status.paused:
- info = await client.capsules.resume(capsule_id)
+ await client.capsules.resume(capsule_id)
- return cls(
+ capsule = cls(
_capsule_id=capsule_id,
_client=client,
_info=info,
)
+ if info.status != Status.running:
+ await capsule.wait_ready()
+
+ return capsule
+
# ── Dual instance/static lifecycle ──────────────────────────
destroy = _DualMethod("_instance_destroy", "_static_destroy")
@@ -224,12 +229,21 @@ class AsyncCapsule:
"""
await self._client.capsules.ping(self._id)
- async def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
+ _POLL_INTERVALS: dict[Status, float] = {
+ Status.starting: 0.5,
+ Status.resuming: 0.5,
+ Status.pausing: 2.0,
+ Status.stopping: 1.0,
+ }
+
+ async def wait_ready(self, timeout: float = 30) -> None:
"""Await until the capsule status is ``running``.
+ Polling interval adapts to the current transient status:
+ 0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
+
Args:
timeout (float): Maximum seconds to wait. Defaults to ``30``.
- interval (float): Polling interval in seconds. Defaults to ``0.5``.
Raises:
TimeoutError: If the capsule does not reach ``running`` state
@@ -246,7 +260,10 @@ class AsyncCapsule:
if info.status in (Status.error, Status.stopped):
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
if info.status == Status.paused:
- info = await self._client.capsules.resume(self._id)
+ await self._client.capsules.resume(self._id)
+ interval = (
+ self._POLL_INTERVALS.get(info.status, 0.5) if info.status else 0.5
+ )
await asyncio.sleep(interval)
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
diff --git a/src/wrenn/capsule.py b/src/wrenn/capsule.py
index 29fe52f..24dd4a5 100644
--- a/src/wrenn/capsule.py
+++ b/src/wrenn/capsule.py
@@ -1,7 +1,7 @@
from __future__ import annotations
-import logging
import builtins
+import logging
import time
from collections.abc import Iterator
from contextlib import contextmanager
@@ -112,9 +112,9 @@ class Capsule:
memory_mb=memory_mb,
timeout_sec=timeout,
)
- self._id = self._info.id
- if self._id is None:
+ if self._info.id is None:
raise RuntimeError("API returned a capsule without an ID")
+ self._id = self._info.id
except Exception:
self._client.close()
raise
@@ -214,14 +214,19 @@ class Capsule:
info = client.capsules.get(capsule_id)
if info.status == Status.paused:
- info = client.capsules.resume(capsule_id)
+ client.capsules.resume(capsule_id)
- return cls(
+ capsule = cls(
_capsule_id=capsule_id,
_client=client,
_info=info,
)
+ if info.status != Status.running:
+ capsule.wait_ready()
+
+ return capsule
+
# ── Dual instance/static lifecycle ──────────────────────────
destroy = _DualMethod("_instance_destroy", "_static_destroy")
@@ -306,12 +311,21 @@ class Capsule:
"""
self._client.capsules.ping(self._id)
- def wait_ready(self, timeout: float = 30, interval: float = 0.5) -> None:
+ _POLL_INTERVALS: dict[Status, float] = {
+ Status.starting: 0.5,
+ Status.resuming: 0.5,
+ Status.pausing: 2.0,
+ Status.stopping: 1.0,
+ }
+
+ def wait_ready(self, timeout: float = 30) -> None:
"""Block until the capsule status is ``running``.
+ Polling interval adapts to the current transient status:
+ 0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
+
Args:
timeout (float): Maximum seconds to wait. Defaults to ``30``.
- interval (float): Polling interval in seconds. Defaults to ``0.5``.
Raises:
TimeoutError: If the capsule does not reach ``running`` state
@@ -328,7 +342,10 @@ class Capsule:
if info.status in (Status.error, Status.stopped):
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
if info.status == Status.paused:
- info = self._client.capsules.resume(self._id)
+ self._client.capsules.resume(self._id)
+ interval = (
+ self._POLL_INTERVALS.get(info.status, 0.5) if info.status else 0.5
+ )
time.sleep(interval)
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
diff --git a/src/wrenn/client.py b/src/wrenn/client.py
index c51b190..ceece27 100644
--- a/src/wrenn/client.py
+++ b/src/wrenn/client.py
@@ -111,7 +111,7 @@ class CapsulesResource:
Raises:
WrennNotFoundError: If no capsule with the given ID exists.
"""
- resp = self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT)
+ resp = self._http.post(f"/v1/capsules/{id}/pause")
return CapsuleModel.model_validate(handle_response(resp))
def resume(self, id: str) -> CapsuleModel:
@@ -227,7 +227,7 @@ class AsyncCapsulesResource:
Raises:
WrennNotFoundError: If no capsule with the given ID exists.
"""
- resp = await self._http.post(f"/v1/capsules/{id}/pause", timeout=_LONG_TIMEOUT)
+ resp = await self._http.post(f"/v1/capsules/{id}/pause")
return CapsuleModel.model_validate(handle_response(resp))
async def resume(self, id: str) -> CapsuleModel:
diff --git a/src/wrenn/exceptions.py b/src/wrenn/exceptions.py
index af16f6c..65ac7e8 100644
--- a/src/wrenn/exceptions.py
+++ b/src/wrenn/exceptions.py
@@ -150,6 +150,9 @@ def handle_response(resp: httpx.Response) -> dict | list:
if resp.status_code == 204:
return {}
+ if not resp.content:
+ return {}
+
return resp.json()
diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py
index 5542c2f..8bcec35 100644
--- a/src/wrenn/models/_generated.py
+++ b/src/wrenn/models/_generated.py
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: openapi.yaml
-# timestamp: 2026-04-22T20:21:34+00:00
+# timestamp: 2026-05-15T07:57:28+00:00
from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
@@ -133,7 +133,10 @@ class Status(StrEnum):
pending = "pending"
starting = "starting"
running = "running"
+ pausing = "pausing"
paused = "paused"
+ resuming = "resuming"
+ stopping = "stopping"
hibernated = "hibernated"
stopped = "stopped"
missing = "missing"
diff --git a/tests/test_capsule_features.py b/tests/test_capsule_features.py
index 825eb52..229a907 100644
--- a/tests/test_capsule_features.py
+++ b/tests/test_capsule_features.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import httpx
import respx
from wrenn.capsule import Capsule, _build_proxy_url
@@ -30,9 +31,13 @@ class TestCapsuleCreate:
@respx.mock
def test_capsule_constructor_creates(self):
respx.post(f"{BASE}/v1/capsules").respond(
- 201, json={"id": "cl-1", "status": "pending", "template": "minimal"}
+ 202, json={"id": "cl-1", "status": "starting", "template": "minimal"}
+ )
+ cap = Capsule(
+ template="minimal",
+ api_key="wrn_test1234567890abcdef12345678",
+ base_url=BASE,
)
- cap = Capsule(template="minimal", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert cap.capsule_id == "cl-1"
assert hasattr(cap, "commands")
assert hasattr(cap, "files")
@@ -40,7 +45,7 @@ class TestCapsuleCreate:
@respx.mock
def test_capsule_create_classmethod(self):
respx.post(f"{BASE}/v1/capsules").respond(
- 201, json={"id": "cl-2", "status": "pending"}
+ 202, json={"id": "cl-2", "status": "starting"}
)
cap = Capsule.create(api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert cap.capsule_id == "cl-2"
@@ -48,9 +53,9 @@ class TestCapsuleCreate:
@respx.mock
def test_capsule_context_manager_kills(self):
respx.post(f"{BASE}/v1/capsules").respond(
- 201, json={"id": "cl-1", "status": "pending"}
+ 202, json={"id": "cl-1", "status": "starting"}
)
- kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
+ kill_route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
with Capsule(api_key="wrn_test1234567890abcdef12345678", base_url=BASE) as cap:
assert cap.capsule_id == "cl-1"
assert kill_route.called
@@ -59,7 +64,7 @@ class TestCapsuleCreate:
def test_capsule_env_var(self, monkeypatch):
monkeypatch.setenv("WRENN_API_KEY", "wrn_from_env_key")
respx.post(f"{BASE}/v1/capsules").respond(
- 201, json={"id": "cl-3", "status": "pending"}
+ 202, json={"id": "cl-3", "status": "starting"}
)
cap = Capsule(base_url=BASE)
assert cap.capsule_id == "cl-3"
@@ -68,17 +73,21 @@ class TestCapsuleCreate:
class TestCapsuleStaticMethods:
@respx.mock
def test_static_destroy(self):
- route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(204)
- Capsule._static_destroy("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
+ route = respx.delete(f"{BASE}/v1/capsules/cl-1").respond(202)
+ Capsule._static_destroy(
+ "cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
+ )
assert route.called
@respx.mock
def test_static_pause(self):
respx.post(f"{BASE}/v1/capsules/cl-1/pause").respond(
- 200, json={"id": "cl-1", "status": "paused"}
+ 202, json={"id": "cl-1", "status": "pausing"}
)
- info = Capsule._static_pause("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
- assert info.status.value == "paused"
+ info = Capsule._static_pause(
+ "cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
+ )
+ assert info.status.value == "pausing"
@respx.mock
def test_static_list(self):
@@ -106,18 +115,24 @@ class TestCapsuleConnect:
respx.get(f"{BASE}/v1/capsules/cl-1").respond(
200, json={"id": "cl-1", "status": "running"}
)
- cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
+ cap = Capsule.connect(
+ "cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
+ )
assert cap.capsule_id == "cl-1"
@respx.mock
def test_connect_paused_resumes(self):
- respx.get(f"{BASE}/v1/capsules/cl-1").respond(
- 200, json={"id": "cl-1", "status": "paused"}
- )
+ get_route = respx.get(f"{BASE}/v1/capsules/cl-1")
+ get_route.side_effect = [
+ httpx.Response(200, json={"id": "cl-1", "status": "paused"}),
+ httpx.Response(200, json={"id": "cl-1", "status": "running"}),
+ ]
respx.post(f"{BASE}/v1/capsules/cl-1/resume").respond(
- 200, json={"id": "cl-1", "status": "running"}
+ 202, json={"id": "cl-1", "status": "resuming"}
+ )
+ cap = Capsule.connect(
+ "cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE
)
- cap = Capsule.connect("cl-1", api_key="wrn_test1234567890abcdef12345678", base_url=BASE)
assert cap.capsule_id == "cl-1"
diff --git a/tests/test_client.py b/tests/test_client.py
index 36adce9..1269233 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -36,10 +36,10 @@ class TestCapsules:
@respx.mock
def test_create(self, client):
respx.post(f"{BASE}/v1/capsules").respond(
- 201,
+ 202,
json={
"id": "sb-1",
- "status": "pending",
+ "status": "starting",
"template": "base-python",
"vcpus": 2,
"memory_mb": 1024,
@@ -48,12 +48,12 @@ class TestCapsules:
resp = client.capsules.create(template="base-python", vcpus=2, memory_mb=1024)
assert isinstance(resp, Capsule)
assert resp.id == "sb-1"
- assert resp.status == Status.pending
+ assert resp.status == Status.starting
@respx.mock
def test_create_defaults(self, client):
respx.post(f"{BASE}/v1/capsules").respond(
- 201, json={"id": "sb-2", "status": "pending"}
+ 202, json={"id": "sb-2", "status": "starting"}
)
resp = client.capsules.create()
assert resp.id == "sb-2"
@@ -77,25 +77,25 @@ class TestCapsules:
@respx.mock
def test_destroy(self, client):
- route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(204)
+ route = respx.delete(f"{BASE}/v1/capsules/sb-1").respond(202)
client.capsules.destroy("sb-1")
assert route.called
@respx.mock
def test_pause(self, client):
respx.post(f"{BASE}/v1/capsules/sb-1/pause").respond(
- 200, json={"id": "sb-1", "status": "paused"}
+ 202, json={"id": "sb-1", "status": "pausing"}
)
resp = client.capsules.pause("sb-1")
- assert resp.status == Status.paused
+ assert resp.status == Status.pausing
@respx.mock
def test_resume(self, client):
respx.post(f"{BASE}/v1/capsules/sb-1/resume").respond(
- 200, json={"id": "sb-1", "status": "running"}
+ 202, json={"id": "sb-1", "status": "resuming"}
)
resp = client.capsules.resume("sb-1")
- assert resp.status == Status.running
+ assert resp.status == Status.resuming
@respx.mock
def test_ping(self, client):
@@ -238,7 +238,7 @@ class TestAsyncClient:
async def test_async_capsules_create(self, async_client):
async with async_client:
respx.post(f"{BASE}/v1/capsules").respond(
- 201, json={"id": "sb-1", "status": "pending"}
+ 202, json={"id": "sb-1", "status": "starting"}
)
resp = await async_client.capsules.create(template="base-python")
assert resp.id == "sb-1"