# `bellona-remote` — Bellona Remote Creative Control Plane Python SDK

> TODO 180.C.29.05 — Python client for automation scripts and notebooks.

A typed Python client over the Bellona **remote gateway**
(`apps/bellona/remote-gateway`). It is the Python counterpart of the TypeScript
SDK (`@oshun/bellona-client` → `/remote`,
`libs/bellona/client/src/remote/client.ts`) and speaks the same gateway REST API.

It covers all twelve operation families of the remote creative control plane:

```
devices · sessions · commands · stream · approvals · artifacts · audit ·
blender · unreal · browser · desktop · file
```

The five adapter families (`blender`/`unreal`/`browser`/`desktop`/`file`) build a
fully **validated** `RemoteCommandEnvelope` — namespace pre-bound, with the
protocol's id-format, command-name, risk-class, approval, rollback, and dry-run
invariants enforced client-side (mirrored from `@bellona/remote-protocol`) — and
submit it over the gateway's `POST /v1/commands` endpoint. A malformed command
fails loud **before** any network call.

## Install

```bash
cd libs/bellona/sdk-python
poetry install            # or: pip install -e .
poetry run pytest         # 44 tests
```

Runtime deps: `httpx`, `pydantic>=2`, `typing-extensions`.

## Quickstart (automation script)

```python
from bellona_remote import BellonaRemoteClient

with BellonaRemoteClient(base_url="http://localhost:4100") as client:
    print(client.health().status)              # gateway liveness
    for device in client.devices.list():        # GET /v1/devices
        print(device.id, device.status)

    # Build + submit a validated blender.render.capture command.
    dispatch = client.blender.run(
        id="command:blender-render-001",
        session_id="session:studio-001",
        issued_by_participant_id="participant:operator-primary",
        target={"deviceId": "device:studio-mac", "commandName": "blender.render.capture"},
        idempotency_key="idem:studio:blender-render:001",
        issued_at="2026-06-21T00:00:00.000Z",
        risk_class="safe-mutation",
        rollback_strategy="compensating_action",
        timeout_class="long-running",
        arguments={"mode": "cycles_still", "outputName": "hero-product"},
    )
    print(dispatch.command["id"], dispatch.status)
```

## Notebook usage

The client has a clean importable API and typed pydantic results, so it works
well in Jupyter. Construct it once, reuse the family groups, and inspect typed
objects in cells:

```python
client = BellonaRemoteClient(base_url="http://localhost:4100",
                             host_token="bellona-host-token-v1.…")
client.diagnose()                 # → RemoteDiagnostics
client.artifacts.list(kind="render")
client.audit.query(session_id="session:studio-001", limit=50)
```

## Method groups (mirrors the TS SDK)

| Group | Gateway endpoints |
| --- | --- |
| `client.devices` | `POST /v1/pairing/exchange`, `GET /v1/devices`, `GET /v1/devices/{id}`, `POST /v1/devices/{id}/revoke` |
| `client.sessions` | `POST /v1/sessions`, `GET /v1/sessions`, `GET /v1/sessions/{id}`, `POST /v1/sessions/{id}/end`, `POST /v1/sessions/{id}/takeover` |
| `client.commands` | `POST /v1/commands`, `GET /v1/commands/{id}`, `POST /v1/commands/{id}/cancel` |
| `client.stream` | `POST /v1/streams`, `GET /v1/streams`, `GET /v1/streams/{id}`, `POST /v1/streams/{id}/source`, `POST /v1/streams/{id}/stop` |
| `client.approvals` | `GET /v1/approvals`, `POST /v1/approvals/{id}/decisions` |
| `client.artifacts` | `GET /v1/artifacts`, `GET /v1/artifacts/{id}` + `file.transfer.*` routing |
| `client.audit` | `GET /v1/timeline`, `GET /v1/audit/export` |
| `client.blender` / `unreal` / `browser` / `desktop` / `file` | `POST /v1/commands` with a namespace-bound, validated envelope |

`client.health()` → `GET /v1/health`; `client.diagnose()` → `GET /v1/diagnostics`.

## Building valid envelopes

`build_command_envelope(namespace, ...)` (and the family `.build(...)` /
`.run(...)` methods) enforce the protocol invariants:

- **IDs** match `^[a-z][a-z0-9-]*:[a-z0-9][a-z0-9._-]*$` (`namespace:value`).
- **Command names** are dotted lowercase and must start with `{namespace}.`.
- **`safe-mutation` / `destructive` / `privileged`** require a
  `rollback_strategy`.
- **`destructive` / `privileged`** require `approval_required=True` plus a
  dry-run plan (`metadata.dryRunSupported=True` + `metadata.dryRunPlanId`).
- **Idempotency keys** are lowercase URL-safe tokens, 8–180 chars.
- **`timeout_ms`** must not exceed the `timeout_class` ceiling.

A violation raises `pydantic.ValidationError` (see
`tests/test_envelope_validity.py`).

## Errors

- `RemoteGatewayError` — any non-2xx gateway response or connectivity failure
  (carries `.code`, `.status_code`, `.payload`; `.is_unreachable` for network
  failures).
- `RemoteCommandApprovalRequiredError` — the gateway accepted the command but
  blocked it pending human approval (HTTP 202); carries `.approval_request_id`.

## Testing

The transport is a seam (`RemoteTransport`); tests inject a mock at that boundary
and assert the SDK builds the right request and parses the result. The real
`httpx` transport is exercised separately via `httpx.MockTransport`
(`tests/test_http_transport.py`).

## End-to-end examples

Runnable end-to-end examples (Blender render/export, Unreal import/place/capture,
browser production-board, cloud-agent control, desktop dialog fallback) live in
[`examples/bellona-remote/`](../../../examples/bellona-remote/) (TODO 180.C.29.07).
