# Python Async SDK

> The asyncio mirror of the sync SDK: AsyncSandboxClient, AsyncSandbox, async connector, async filesystem, and async command executor.

- Repository: kubernetes-sigs/agent-sandbox
- GitHub: https://github.com/kubernetes-sigs/agent-sandbox
- Human wiki: https://grok-wiki.com/public/wiki/kubernetes-sigs-agent-sandbox-c3f2597a654a
- Complete Markdown: https://grok-wiki.com/public/wiki/kubernetes-sigs-agent-sandbox-c3f2597a654a/llms-full.txt

## Source Files

- `clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py`
- `clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox.py`
- `clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_connector.py`
- `clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_k8s_helper.py`
- `clients/python/agentic-sandbox-client/k8s_agent_sandbox/commands/async_command_executor.py`
- `clients/python/agentic-sandbox-client/k8s_agent_sandbox/files/async_filesystem.py`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_connector.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_connector.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_k8s_helper.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_k8s_helper.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/commands/async_command_executor.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/commands/async_command_executor.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/files/async_filesystem.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/files/async_filesystem.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/models.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/models.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/__init__.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/__init__.py)
</details>

# Python Async SDK

The Python Async SDK is the `asyncio`-native mirror of the synchronous `SandboxClient` stack. It is intended for asynchronous applications (FastAPI services, agent frameworks built on `asyncio`, concurrent batch tools) where awaiting `kubectl`-like watches, HTTP calls to the sandbox router, and per-sandbox lifecycle work must not block the event loop. The async layer is shipped as an *optional* extra: when the underlying dependencies (`kubernetes_asyncio`, `httpx`) are absent, importing `AsyncSandboxClient` raises a guided `ImportError` pointing at `pip install k8s-agent-sandbox[async]`.

Functionally, the async API is intentionally close to the sync API — same connection-config model, same `commands` / `files` shape on a sandbox handle, same SandboxClaim → Sandbox resolution semantics — but with two deliberate differences: (1) it does **not** support `SandboxLocalTunnelConnectionConfig` (no `kubectl port-forward` subprocess), and (2) it has no `atexit` cleanup fallback, because async cleanup cannot run from an `atexit` handler. Callers are expected to use `async with` or to call `delete_all()` + `close()` explicitly.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/__init__.py:26-35](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py:14-58]()

## Module Layout

The async surface lives alongside the sync surface in the `k8s_agent_sandbox` package. Each sync module has an `async_*` twin that re-uses the shared models and helpers where possible (e.g. the path sanitizer from `files.filesystem.Filesystem._safe_upload_path`).

| Concern | Async module | Public symbol |
| --- | --- | --- |
| Lifecycle / registry | `async_sandbox_client.py` | `AsyncSandboxClient` |
| Sandbox handle | `async_sandbox.py` | `AsyncSandbox` |
| HTTP transport | `async_connector.py` | `AsyncSandboxConnector` |
| Kubernetes API | `async_k8s_helper.py` | `AsyncK8sHelper` |
| Command execution | `commands/async_command_executor.py` | `AsyncCommandExecutor` |
| Filesystem | `files/async_filesystem.py` | `AsyncFilesystem` |
| Shared models | `models.py` | `SandboxConnectionConfig`, `ExecutionResult`, `FileEntry`, … |

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox.py:17-23](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/files/async_filesystem.py:17-21]()

## Architecture

The async stack composes four collaborators behind the `AsyncSandboxClient` facade. The client owns the `AsyncK8sHelper` (long-lived, shared across sandboxes) and the registry of live handles; each `AsyncSandbox` owns its own `AsyncSandboxConnector` plus `AsyncCommandExecutor` and `AsyncFilesystem` views over that connector.

```mermaid
flowchart LR
    subgraph User["Caller (asyncio app)"]
        APP[await client / sandbox]
    end

    subgraph Client["AsyncSandboxClient"]
        REG["_active_connection_sandboxes\n(namespace, claim_name) → AsyncSandbox"]
        LOCK["asyncio.Lock"]
        CLAIM["_create_claim / _wait_for_sandbox_ready / _delete_claim"]
    end

    subgraph Handle["AsyncSandbox"]
        CMDS["commands : AsyncCommandExecutor"]
        FILES["files : AsyncFilesystem"]
        CONN["connector : AsyncSandboxConnector"]
        STATE["_is_closed / get_pod_name / get_pod_ip"]
    end

    subgraph K8s["AsyncK8sHelper (kubernetes_asyncio)"]
        WATCH["watch.Watch streams"]
        CRD["CustomObjectsApi: SandboxClaim / Sandbox / Gateway"]
    end

    subgraph Sandbox["Sandbox pod / router / gateway"]
        ROUTER["/execute, /upload, /download/{path}, /list/{path}, /exists/{path}"]
    end

    APP --> Client
    Client --> Handle
    CLAIM --> K8s
    STATE --> K8s
    CMDS --> CONN
    FILES --> CONN
    CONN -->|httpx.AsyncClient| ROUTER
    K8s --> CRD
    K8s --> WATCH
```

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py:41-105](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox.py:26-95](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_connector.py:38-91]()

## `AsyncSandboxClient`

`AsyncSandboxClient` is a generic, registry-based lifecycle manager. The generic parameter `T = TypeVar("T", bound=AsyncSandbox)` plus the class attribute `sandbox_class: type[T] = AsyncSandbox` are the hook that subclasses (e.g. snapshot-aware variants under `gke_extensions/`) use to substitute a richer handle type without rewriting lifecycle logic.

### Construction and required configuration

The constructor refuses to build a client without a `connection_config`. This is stricter than the sync client and is deliberate: `SandboxLocalTunnelConnectionConfig` is rejected upstream by `AsyncSandboxConnector`, so there is no implicit local-dev fallback. The error message points callers explicitly at the supported configs and at the sync client for local development.

```python
# clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py:62-85
def __init__(self, connection_config: SandboxConnectionConfig | None = None,
             tracer_config: SandboxTracerConfig | None = None):
    if connection_config is None:
        raise ValueError(
            "connection_config is required for AsyncSandboxClient. "
            "Use SandboxDirectConnectionConfig, SandboxGatewayConnectionConfig, or "
            "SandboxInClusterConnectionConfig. ..."
        )
    ...
    self.k8s_helper = AsyncK8sHelper()
    self._active_connection_sandboxes: dict[tuple[str, str], T] = {}
    self._lock = asyncio.Lock()
```

The registry key is `(namespace, claim_name)` and all mutations are guarded by `asyncio.Lock` to keep concurrent `create_sandbox` / `get_sandbox` / `delete_sandbox` calls coherent.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py:38-85]()

### Context-manager cleanup contract

Async cleanup cannot run from `atexit`, so the class only supports two safe patterns:

1. `async with AsyncSandboxClient(...) as client: ...` — `__aexit__` calls `delete_all()` followed by `close()`.
2. Manual: `await client.delete_all(); await client.close()`.

If neither pattern is followed, in-flight SandboxClaims will outlive the process and accumulate in the cluster. `close()` walks every tracked sandbox, awaits `_close_connection()` on each, then shuts down the shared `AsyncK8sHelper` API client.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py:50-105](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py:304-313]()

### Lifecycle API

| Method | Purpose | Notes |
| --- | --- | --- |
| `create_sandbox(template, namespace, sandbox_ready_timeout, labels, warmpool, *, shutdown_after_seconds)` | Provision a new `SandboxClaim` and wait until the underlying Sandbox is `Ready`. | `shutdown_after_seconds` writes `spec.lifecycle.shutdownTime` + `shutdownPolicy=Delete`; on any failure or `CancelledError`, the in-flight claim is deleted under `asyncio.shield`. |
| `get_sandbox(claim_name, namespace, resolve_timeout, template_name)` | Reattach to an existing claim. | When `template_name` is supplied and `spec.sandboxTemplateRef.name` differs, raises `ValueError` (refuse-to-reattach guard); other failures raise `SandboxNotFoundError`. |
| `list_active_sandboxes()` | Tuples currently held in the registry. | Lazily prunes entries whose `is_active` is `False`. |
| `list_all_sandboxes(namespace, label_selector)` | Cluster-side listing of `SandboxClaim` names. | Forwards `label_selector` to the K8s list call. |
| `delete_sandbox(claim_name, namespace)` | Terminate one sandbox. | If tracked, calls `sandbox.terminate()`; otherwise deletes the claim directly. Failures are logged, not raised. |
| `delete_all()` | Bulk delete every tracked sandbox. | Per-sandbox errors are logged and do not abort the sweep. |

Cancellation safety in `create_sandbox` is non-trivial: the `except (Exception, asyncio.CancelledError)` arm wraps cleanup in `asyncio.shield(self._delete_claim(...))` so that a task cancellation racing with claim creation still releases the cluster-side resource before the exception propagates.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py:107-313]()

### Label validation

Label validation is identical to the sync client and lives on `AsyncSandboxClient` as static methods, anchored by two regexes and the Kubernetes-standard length limits (`63` for names, `253` for prefixes). Keys may be `prefix/name` (prefix must be a DNS subdomain) or just `name`; values are validated with the same name regex.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py:315-361]()

### Trace context propagation

`_create_claim`, `_wait_for_sandbox_ready`, and `_delete_claim` are wrapped with `@async_trace_span(...)`. When tracing is enabled and a tracing manager is attached, `_create_claim` injects the current trace context as JSON into the claim's `metadata.annotations["opentelemetry.io/trace-context"]`, allowing the controller side to continue the same trace. Span attributes record `sandbox.claim.name` plus optional lifecycle attributes.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py:363-399]()

## `AsyncSandbox`

`AsyncSandbox` is a thin coordinator: a value object plus three composed services (`connector`, `_commands`, `_files`). Like the client, it rejects `connection_config=None` because the absence of a local-tunnel fallback makes the parameter mandatory.

```python
# clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox.py:62-83
self.connector = AsyncSandboxConnector(
    sandbox_id=self.sandbox_id,
    namespace=self.namespace,
    connection_config=self.connection_config,
    k8s_helper=self.k8s_helper,
    get_pod_ip=self.get_pod_ip,
)
...
self._commands = AsyncCommandExecutor(self.connector, self.tracer, self.trace_service_name)
self._files = AsyncFilesystem(self.connector, self.tracer, self.trace_service_name)
```

Two K8s-backed accessors expose live pod state:

- `get_pod_name()` — memoized after the first lookup. Reads `metadata.annotations[POD_NAME_ANNOTATION]` from the Sandbox object; falls back to `sandbox_id` when the annotation is absent.
- `get_pod_ip()` — **not** memoized. The pod IP can change after a restart (e.g. `spec.replicas` scaled to `0` and back), so every call hits the K8s API and returns `status.podIPs[0]` or `None`.

`_close_connection()` is idempotent (guards on `_is_closed`), closes the connector's `httpx` client, drops the `_commands` / `_files` references (so `is_active` returns `False`), and ends the lifecycle tracing span. `terminate()` is the destructive variant: `_close_connection()` then `delete_sandbox_claim()`.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox.py:40-143]()

## `AsyncSandboxConnector`

The connector wraps a single `httpx.AsyncClient` (60 s default timeout, `AsyncHTTPTransport(retries=3)` at the transport layer) and resolves the base URL lazily per connection mode.

### Supported connection modes

```text
DirectConnection      api_url                                  ── injected router headers
GatewayConnection     wait_for_gateway_ip → http://<ip>        ── injected router headers
InClusterConnection   http://<id>.<ns>.svc.cluster.local:<p>   ── no router headers
                      or http://<podIP>:<p> if use_pod_ip      ── no router headers
LocalTunnel           rejected at __init__ (ValueError)
```

The `_inject_router_headers` flag is `False` only for `SandboxInClusterConnectionConfig`. In all other modes, every request carries `X-Sandbox-ID`, `X-Sandbox-Namespace`, `X-Sandbox-Port`, and (when available) `X-Sandbox-Pod-IP`, which lets the router route to the correct backend.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_connector.py:38-91](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_connector.py:125-149]()

### Pod-IP resolution and the auth latch

The connector caches a pod IP for routing speed, but it must defend against permission errors:

- First time around it calls the `get_pod_ip` callback (provided by `AsyncSandbox.get_pod_ip`).
- If the call raises and the underlying response is `401`/`403`, `_pod_ip_auth_failed` is **permanently** set on this client instance — pod-IP routing is disabled for its lifetime to avoid hammering the K8s API with a token that cannot read sandboxes.
- Transient errors are logged at debug and retried on later requests.

For `SandboxInClusterConnectionConfig`, `_resolve_base_url` upgrades the URL once pod-IP resolution succeeds, otherwise it falls back to cluster DNS.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_connector.py:69-149]()

### Retry and cache invalidation

`send_request` retries up to `MAX_RETRIES = 5` times on `{500, 502, 503, 504}` with exponential backoff (`BACKOFF_FACTOR * 2**attempt`, i.e. 0.5 s, 1 s, 2 s, …). On any HTTP error it raises `SandboxRequestError` and clears caches that may have gone stale:

- For `SandboxGatewayConnectionConfig`, the `_base_url` is dropped so the next request re-queries the gateway IP.
- Pod-IP caching state (`_pod_ip_resolved`, `_cached_pod_ip_url`, `_pod_ip`) is reset.

`close()` calls `httpx.AsyncClient.aclose()` and clears the same caches.

```mermaid
sequenceDiagram
    participant Caller as AsyncCommandExecutor/AsyncFilesystem
    participant Conn as AsyncSandboxConnector
    participant K8s as AsyncK8sHelper
    participant Router as Sandbox router / pod

    Caller->>Conn: send_request(METHOD, endpoint, ...)
    Conn->>Conn: _resolve_base_url()
    alt Gateway mode, no cached IP
        Conn->>K8s: wait_for_gateway_ip(...)
        K8s-->>Conn: ip_address
    else InCluster + use_pod_ip
        Conn->>K8s: get_pod_ip via callback
        K8s-->>Conn: podIPs[0] or None
    end
    Conn->>Router: httpx.AsyncClient.request(...)
    alt 5xx and attempt < 5
        Router-->>Conn: retryable status
        Conn->>Conn: asyncio.sleep(0.5 * 2^attempt)
        Conn->>Router: retry
    end
    Router-->>Caller: httpx.Response (or SandboxRequestError)
```

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_connector.py:33-208]()

## `AsyncK8sHelper`

`AsyncK8sHelper` wraps `kubernetes_asyncio` and is shared by the client and all of its sandboxes through `AsyncSandbox.k8s_helper`. Initialization is lazy and once-only: an `asyncio.Lock` guards `_ensure_initialized`, which tries in-cluster config first and falls back to `load_kube_config()`. `close()` shuts down the shared `ApiClient` and resets the latch so a later call would re-initialize cleanly.

### CRD operations

| Method | Resource | Behavior |
| --- | --- | --- |
| `create_sandbox_claim(name, template, namespace, annotations, labels, lifecycle, warmpool)` | `SandboxClaim` | Builds a manifest with `spec.sandboxTemplateRef`, optional `spec.lifecycle` and `spec.warmpool`; calls `create_namespaced_custom_object`. |
| `resolve_sandbox_name(claim_name, namespace, timeout)` | `SandboxClaim` (watch) | Watches the claim until `status.sandbox.name` is populated (warm-pool adoption may produce a different name from the claim). Surfaces `Ready=False` + `TemplateNotFound` as `SandboxTemplateNotFoundError`. Supports both `name` (post-rename) and legacy `Name` keys. |
| `wait_for_sandbox_ready(name, namespace, timeout)` | `Sandbox` (watch) | Watches until a condition with `type=Ready, status=True`. Returns the first `status.podIPs` entry (or `None` on older controllers). |
| `delete_sandbox_claim(name, namespace)` | `SandboxClaim` | `404` swallowed; other API errors raise `SandboxNotFoundError`. |
| `get_sandbox`, `get_sandbox_claim` | both | Return the raw object dict or `None` on `404`. |
| `list_sandbox_claims(namespace, label_selector)` | `SandboxClaim` | Returns `metadata.name` for each item, optionally filtered by selector. |
| `wait_for_gateway_ip(gateway_name, namespace, timeout)` | `Gateway` (watch) | Watches the Gateway custom resource for `status.addresses[0].value`. |

All watch-based methods (`resolve_sandbox_name`, `wait_for_sandbox_ready`, `wait_for_gateway_ip`) follow the same pattern: compute a remaining-time budget, open a `watch.Watch()`, iterate `w.stream(...)` with `timeout_seconds=remaining`, and always `await w.close()` in a `finally` block to release the streaming HTTP connection — important because `kubernetes_asyncio` watches hold a live connection until closed.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_k8s_helper.py:37-331]()

## `AsyncCommandExecutor`

The executor is a thin wrapper over `connector.send_request("POST", "execute", json={"command": ...}, timeout=...)`. The response is parsed into the shared `ExecutionResult` Pydantic model (`stdout`, `stderr`, `exit_code`); decode or schema failures are raised as `RuntimeError` so callers can distinguish them from `SandboxRequestError`. The whole `run` method is wrapped in `@async_trace_span("run")`, with `sandbox.command` and `sandbox.exit_code` recorded as span attributes when tracing is recording.

```python
# clients/python/agentic-sandbox-client/k8s_agent_sandbox/commands/async_command_executor.py:30-56
@async_trace_span("run")
async def run(self, command: str, timeout: int = 60) -> ExecutionResult:
    ...
    response = await self.connector.send_request("POST", "execute", json={"command": command}, timeout=timeout)
    ...
    return ExecutionResult(**response.json())
```

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/commands/async_command_executor.py:20-57](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/models.py:18-22]()

## `AsyncFilesystem`

`AsyncFilesystem` mirrors the four core sync filesystem methods, all decorated with `@async_trace_span(...)`:

| Method | HTTP | Notes |
| --- | --- | --- |
| `write(path, content, timeout, allow_unsafe_paths)` | `POST /upload` | Encodes `str` content as UTF-8 bytes; routes through `Filesystem._safe_upload_path` unless `allow_unsafe_paths=True`. |
| `read(path, timeout, allow_unsafe_paths)` | `GET /download/{path}` | Same path sanitization gate. Returns `response.content` bytes. |
| `list(path, timeout)` | `GET /list/{path}` | Parses into `list[FileEntry]`. |
| `exists(path, timeout)` | `GET /exists/{path}` | Returns the `exists` boolean from the JSON body. |

The path-safety contract is shared with the sync class via direct call into `Filesystem._safe_upload_path` (re-implementing it asynchronously would risk drift). The accompanying comment explains why `os.path.basename` is not sufficient — `basename("foo\x00../etc/passwd")` returns the string unchanged because of the embedded NUL, which would then truncate at the C layer when handed to the OS. The hardened sanitizer rejects empty / bare-`.`, embedded NUL and ASCII control characters, and any `..` segment after normalisation. URL-path interpolation goes through `urllib.parse.quote(path, safe="")` on every read/list/exists call.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/files/async_filesystem.py:24-139]()

## Optional dependency boundary

The async stack is opt-in. The top-level `__init__.py` imports `AsyncSandboxClient` from `async_sandbox_client`; on `ImportError` it replaces the symbol with a stub class whose `__init__` raises a guided `ImportError` telling the caller to install the `async` extra. This keeps the sync surface usable on minimal installations while still letting users `from k8s_agent_sandbox import AsyncSandboxClient` and discover the missing-extras error at instantiation time rather than at import time.

```python
# clients/python/agentic-sandbox-client/k8s_agent_sandbox/__init__.py:26-35
try:
    from .async_sandbox_client import AsyncSandboxClient
except ImportError:
    class AsyncSandboxClient:  # type: ignore[no-redef]
        def __init__(self, *args, **kwargs):
            raise ImportError(
                "AsyncSandboxClient requires the 'async' extras. "
                "Install with: pip install k8s-agent-sandbox[async]"
            )
```

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/__init__.py:26-35]()

## Sync vs. Async at a glance

The two stacks share models, label-validation rules, path sanitization, and tracing decorators. The differences worth knowing before choosing:

| Aspect | Sync | Async |
| --- | --- | --- |
| Local development via `kubectl port-forward` | Supported (`SandboxLocalTunnelConnectionConfig`) | **Not supported** — connector rejects it; client refuses `connection_config=None` |
| Process-exit safety net | `atexit` cleanup | None; must use `async with` or explicit `delete_all` + `close` |
| HTTP transport | `requests` | `httpx.AsyncClient` (transport-level retries=3) |
| K8s API | `kubernetes` | `kubernetes_asyncio` (watches must be closed in `finally`) |
| Mutual exclusion | `threading.Lock` | `asyncio.Lock` |
| Cancellation handling on create | Exceptions | `Exception | CancelledError`, claim cleanup under `asyncio.shield` |

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py:50-173](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_connector.py:55-91](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_k8s_helper.py:107-207]()

## Putting it together

A minimal end-to-end async session — provision a sandbox, run a command, upload and download a file, and let the context manager clean up — exercises every layer described above:

```python
from k8s_agent_sandbox import AsyncSandboxClient
from k8s_agent_sandbox.models import SandboxDirectConnectionConfig

async def main():
    config = SandboxDirectConnectionConfig(api_url="http://router.example")
    async with AsyncSandboxClient(connection_config=config) as client:
        sandbox = await client.create_sandbox(
            template="python-sandbox-template",
            shutdown_after_seconds=600,
        )
        result = await sandbox.commands.run("echo hello")
        await sandbox.files.write("/tmp/note.txt", "hi")
        data = await sandbox.files.read("/tmp/note.txt")
        # __aexit__ -> delete_all() -> client.close() releases all claims
```

The `async with` block is what makes this safe: it guarantees `delete_all()` runs even if the body raises, which is the only reliable cleanup path given the deliberate absence of an `atexit` fallback.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py:41-105](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/async_sandbox_client.py:107-173]()
