Metadata-Version: 2.5
Name: miosa
Version: 2.12.0
Summary: Official Python SDK for MIOSA - the AI cloud platform for sandboxes, desktop computers, deployments, managed databases, S3 storage, and volumes.
Project-URL: Homepage, https://miosa.ai
Project-URL: Documentation, https://miosa.ai/docs/sdks/python
Project-URL: Repository, https://github.com/Miosa-osa/miosa-python
Project-URL: Issues, https://github.com/Miosa-osa/miosa-python/issues
Project-URL: Changelog, https://github.com/Miosa-osa/miosa-python/blob/main/CHANGELOG.md
Project-URL: Source, https://github.com/Miosa-osa/miosa-python
Author-email: MIOSA <dev@miosa.ai>
License-Expression: MIT
Keywords: agents,ai,cloud,database,deploy,desktop,desktop-control,firecracker,mcp,microvm,miosa,postgres,sandbox,sdk,storage,vm
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Distributed Computing
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1.0.0,>=0.25.0
Requires-Dist: pydantic<3.0.0,>=2.0.0
Provides-Extra: dev
Requires-Dist: h2<5.0.0,>=4.0.0; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: pyyaml<7,>=6.0.2; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Requires-Dist: websockets>=11.0; extra == 'dev'
Provides-Extra: events
Requires-Dist: websockets>=11.0; extra == 'events'
Provides-Extra: http2
Requires-Dist: h2<5.0.0,>=4.0.0; extra == 'http2'
Requires-Dist: httpx[http2]<1.0.0,>=0.25.0; extra == 'http2'
Description-Content-Type: text/markdown

# miosa (Python)

> Official Python SDK for MIOSA — the AI cloud platform for sandboxes, computers, deployments, and managed data.

[![PyPI version](https://img.shields.io/pypi/v/miosa.svg)](https://pypi.org/project/miosa/)
[![Python versions](https://img.shields.io/pypi/pyversions/miosa.svg)](https://pypi.org/project/miosa/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docs](https://img.shields.io/badge/docs-miosa.ai%2Fdocs-blue)](https://miosa.ai/docs/sdks/python)

## Install

```bash
pip install miosa
# or
uv add miosa
# or
poetry add miosa
```

Requires Python 3.9+. Dependencies: `httpx`, `pydantic v2`.

## Quickstart

```python
from miosa import Miosa

miosa = Miosa(api_key="msk_live_...")

# Create or resume a persistent sandbox workspace, then work inside it.
sb = miosa.sandboxes.create_agent_workspace(
    "my-build",
    external_workspace_id="customer-workspace-123",
)

result = sb.exec.run("python --version")
print(result.stdout)   # Python 3.12.x

sb.files.write("/workspace/hello.py", "print('hello from miosa')")
out = sb.exec.run("python /workspace/hello.py")
print(out.stdout)      # hello from miosa

print(sb.preview.create(port=8000)["url"])
# https://8000-<slug>.sandbox.miosa.ai/

sb.snapshots.create("after-hello-world")
sb.pause()
```

## What's included

| Resource | Description |
|---|---|
| `miosa.computers` | Firecracker microVMs — full lifecycle, exec, files, services |
| `miosa.sandboxes` | Lightweight code-execution VMs via the `/sandboxes` route |
| `miosa.deployments` | Versioned production releases with rollback |
| `miosa.databases` | Managed Postgres / Redis lifecycle |
| `miosa.storage` | S3-compatible object storage |
| `miosa.volumes` | Persistent block storage |
| `miosa.api_keys` | Programmatic API key management |
| `miosa.usage` | Usage reports per workspace, per external tenant |
| `miosa.settings` | Workspace config, branding, BYOK provider keys |
| `miosa.webhooks` | Outgoing tenant webhooks — CRUD, test, delivery history |
| `miosa.open_computers` | BYOC host management — register your own machines |
| `miosa.devices` | Unified device facade for sandbox/computer list, exec, files, previews |
| `miosa.connectors` | Provider credentials and short-lived runtime tokens |
| `miosa.runtime_env` | Inherited tenant/workspace/project runtime environment variables |
| `miosa.environments` | Machine templates — repositories, variables, tools and credentials a new sandbox or computer inherits |
| `miosa.deployment_environments` | Deployment-environment inventory (`/deployment-environments`) |
| `miosa.snapshot_history` | Account-wide snapshot history, file browser and fork |
| `miosa.named_snapshots` | Named (pinned) snapshots and their deploy/fork |
| `miosa.commands` | The synchronous commands endpoint on sandboxes and computers |
| `miosa.agent_steering` | Steer the agent attached to a machine (`prompts` / `stop`) |
| `miosa.bill_to` | Which organization a create bills |
| `miosa.member_caps` | Per-member usage and concurrency caps |
| `miosa.agent_settings` | Agents tab — harnesses, models, credentials, settings |
| `miosa.agent_signins` | Sign in a workspace's own Claude / ChatGPT / Kimi / Mistral account |
| `miosa.agent_accounts` | Connected agent accounts |
| `miosa.agent_triggers` | Agent triggers — schedule, webhook, event, forge, email |
| `miosa.agent_chats` | A chat's harness context use and compaction |
| `miosa.connections` | One read-model over models, apps and tool servers |
| `miosa.ai_gateway` | AI Gateway policies, budget, limits, settings and request log |
| `miosa.account` | `whoami` — the credential's user, org, workspace and scopes |
| `miosa.workflows` | Visual workflows — CRUD, publish, run, versions, runs and steps |
| `miosa.service_accounts` | Service accounts and their API keys |
| `miosa.sse_ticket` | Short-lived SSE tickets |
| `miosa.event_subscriptions` | Platform event subscriptions and delivery replay |
| `miosa.builder_apps` | Builder apps — data, automations, approvals, publish |
| `miosa.ws` | WebSocket surfaces — sandbox terminal, TCP tunnels, SSH tunnel |
| `miosa.ssh` | SSH kit — ssh-info, certificates, config snippet, ready command |
| `miosa.sandbox_batches` | Batch sandbox create, items, cancel and the batch event stream |
| `miosa.operations` | Long-running operations — get, events, cancel |
| `miosa.notifications` | Notifications — list, unread count, mark read, stream |
| `miosa.alerts` | Alerts — list and resolve |
| `miosa.activity` | Tenant activity feed |

## Computers and Desktop Access

`computer` is the user-facing product. `miosa-desktop` is the desktop computer
template/profile. The canonical fast desktop profile is:

```python
computer = miosa.computers.create(
    name="agent-desktop",
    template_type="miosa-desktop",
    size="small",  # 2 vCPU / 4 GB
)
```

Authenticated platform desktop links do not need a viewer password. Raw
external viewer links such as `*.computer.miosa.ai/desktop/index.html` are
password-gated; rotate the password when you need to share that raw link:

```python
status = computer.viewer_password()
rotated = computer.rotate_viewer_password()
print(rotated["viewer_password"])
```

## Connect vs Egress

Use **Connect** when your product needs to manage provider credentials for
agents, sandboxes, computers, or deployments. Connect owns the product-level
contract: connectors, installations, project links, inherited defaults,
short-lived tokens, white-label attribution, and runtime bindings.

Use **Egress** when you need the low-level security boundary: encrypted secret
storage, outbound host allowlists, placeholder token exchange, and audit logs.
Egress is the enforcement layer under Connect. Most white-label apps should
call `miosa.connectors` and runtime binding helpers first; only call
`miosa.secrets`, `miosa.network`, or `miosa.audit` when you are building
security/admin controls directly.

```python
import os

connector = miosa.connectors.create(
    "anthropic",
    name="workspace-claude",
    scope="workspace",
    external_workspace_id="clinic-iq",
    value=os.environ["ANTHROPIC_API_KEY"],
)

# Runtime-level materialization: the sandbox sees ANTHROPIC_API_KEY, but your
# app does not get the raw provider key back from MIOSA.
miosa.connectors.materialize_defaults(
    workspace_id="workspace-id",
    resource_type="sandbox",
    resource_id=sb.id,
    target="agent",
    external_workspace_id="clinic-iq",
)
```

## Agent workspaces

For AI builders, code assistants, and white-label agent products, the sandbox
is the development machine. Do not run builds on the user's laptop and upload
the result as the primary flow. Create or resume a stable sandbox workspace,
write files under `/workspace`, run package installs/tests/builds inside the
sandbox, expose previews from the sandbox, and publish from that sandbox.

```python
sb = miosa.sandboxes.get_or_create(
    "acme-marketing-page",
    template_id="miosa-sandbox",
    persistent=True,
    timeout_sec=86_400,
    idle_timeout_sec=1800,
    snapshot_expiration_days=30,
    keep_last_snapshots=1,
    external_workspace_id="acme",
    external_user_id="jane",
    wait_until_ready=True,
)

sb.files.write_files([
    {"path": "/workspace/package.json", "data": '{"scripts":{"dev":"vite --host 0.0.0.0"}}'},
    {"path": "/workspace/index.html", "data": '<main id="app"></main>'},
])

for event in sb.exec_stream("cd /workspace && npm install && npm run dev -- --port 3000"):
    print(event)

preview = sb.preview.create(port=3000)
print(preview["url"])
```

For platform agents that plan work outside the sandbox and then hand execution
to a runtime inside the sandbox/computer, use the unified device API:

```python
miosa.agent_runtime_profiles.create(
    workspace_id="clinic-workspace",
    name="ClinicIQ Claude Code",
    runtime="claude-code",
    is_default=True,
    connectors=[
        "anthropic/cliniciq",
        {
            "uid": "refero",
            "type": "mcp",
            "managed": True,
            "server_url": "https://api.refero.design/mcp",
        },
    ],
    env={"ANTHROPIC_API_KEY": "miosa-managed:anthropic/cliniciq"},
    metadata={"model": "claude-opus-4.8", "white_label_client": "cliniciq"},
)

miosa.devices.write_file(
    sb.id,
    "/workspace/PLAN.md",
    content="# Build plan\nUse the customer profile and export /workspace/out.",
)

build = miosa.devices.exec(
    sb.id,
    "npm install && npm run build",
    cwd="/workspace",
    timeout_ms=600_000,
)

artifact = miosa.devices.read_file(sb.id, "/workspace/out/report.pdf")
```

`create_agent_workspace()` is a convenience wrapper around `get_or_create()`
with builder defaults: a 24-hour activity cap, thirty-minute idle
snapshot/pause, readiness waiting, 30-day snapshot-retention metadata,
keep-last-snapshot metadata, and metadata that marks the sandbox as an agent
workspace.

That mirrors the Vercel-style persistent-sandbox model without keeping CPU
running forever: activity extends the running session, idle workspaces are
checkpointed and paused, and later sessions resume from the checkpoint. Use
`extend(86_400)` before long builds, `pause()` when the user is done for now,
`resume()` for the next session, `snapshots.create()` for manual checkpoints,
and `destroy()` only when the workspace should be permanently deleted.

## Agent SDK

The Python SDK includes a small agent loop plus provider adapters and a MIOSA
tool catalogue. This is the quickest path when you want an LLM to operate
MIOSA computers directly.

```python
from miosa.agent import Agent, groq_provider

agent = Agent(
    provider=groq_provider(
        api_key="gsk_...",
        model="moonshotai/kimi-k2-instruct-0905",
    ),
    miosa_api_key="msk_live_...",
)

result = agent.run(
    "Create a sandbox workspace, write /workspace/hello.py, run it, show me "
    "the output, snapshot it, then pause the sandbox.",
    max_iterations=10,
)

print(result.final_text)
```

Built-in agent tools expose the computer primitive in plain terms:

| Tool | Does |
|---|---|
| `create_sandbox` | Create/resume a persistent code sandbox workspace for shell/Python/Node/files. |
| `create_computer` | Boot a general MIOSA computer for GUI, services, previews, files. |
| `list_computers` | List active computers, sandboxes, and desktops. |
| `get_computer` | Fetch status, template, size, and public URL. |
| `exec` / `exec_python` | Run bash or Python inside the computer. |
| `read_file` / `write_file` / `list_files` | Work with files inside the computer. |
| `preview_url` | Get the public HTTPS URL for a service port. |
| `pause_sandbox` / `resume_sandbox` | Stop/resume a persistent workspace without deleting files. |
| `extend_sandbox` | Extend the activity timeout before long installs, builds, or agent tasks. |
| `snapshot_sandbox` | Create a checkpoint after dependency install or a good edit. |
| `deploy_sandbox` | Publish to normal MIOSA Deploy. |
| `deploy_docker` | Publish to the workspace App Engine appliance. |
| `destroy_computer` | Release compute resources when done. |

Provider factories include `openai_provider`, `groq_provider`,
`deepseek_provider`, `openrouter_provider`, `together_provider`,
`fireworks_provider`, `mistral_provider`, `cerebras_provider`,
`perplexity_provider`, `xai_provider`, `ollama_provider`, `lm_studio_provider`,
and `openai_compatible_provider` for custom routers.

Sandbox-first builder examples live in `examples/`:

See [BUILDER_GUIDE.md](./BUILDER_GUIDE.md) for the full “build your own
Lovable with MIOSA sandboxes” product pattern.

| Example | What it builds |
|---|---|
| `agent_sandbox_website_builder.py` | A Lovable-style website builder flow. |
| `agent_sandbox_app_builder.py` | A small app builder with a dev server and preview. |
| `agent_sandbox_artifact_builder.py` | Markdown/doc artifacts in `/workspace/artifacts`. |
| `agent_sandbox_slide_deck_builder.py` | A deck workspace under `/workspace/deck`. |

Set `GROQ_API_KEY` and `MIOSA_API_KEY`, then run one of the examples. They keep
the sandbox alive with `miosa_tool_options={"allow_destroy": False}` so your app
can show previews, inspect generated files, or publish later.

The agent should treat a MIOSA sandbox like the actual development machine:
write source files under `/workspace`, run package managers/tests/builds inside
the sandbox, start dev servers inside the sandbox, stream logs/tool results back
to your UI, and only publish after a sandbox preview passes a smoke check.

## File operations

```python
# Write / read
computer.files.write_file("/workspace/config.json", '{"key": "value"}')
text = computer.files.read_file("/workspace/config.json")

# Upload / download
computer.files.upload("/local/report.pdf", "/workspace/report.pdf")
raw_bytes = computer.files.download("/workspace/report.pdf")

# List / stat / mkdir / rename / chmod
entries = computer.files.list("/workspace")
stat    = computer.files.stat("/workspace/config.json")
computer.files.mkdir("/workspace/output", recursive=True)
computer.files.rename("/workspace/old.txt", "/workspace/new.txt")
computer.files.chmod("/workspace/run.sh", 0o755)
```

## Services (background processes)

```python
svc = computer.services.create(
    name="api",
    command="uvicorn app:main --port 8080",
    working_dir="/workspace",
    env={"DEBUG": "1"},
    port=8080,
)

for log in computer.services.logs(svc.id, follow=True):
    print(log.stream, log.line)

computer.services.restart(svc.id)
computer.services.delete(svc.id)
```

## Publish from sandbox

Sandbox previews are mutable builder URLs. Publishing creates a durable
deployment version with a stable `url` / `public_url`.

```python
sb = miosa.sandboxes.create(name="clinic-intake", template_id="miosa-sandbox")
sb.files.write_file("/workspace/index.html", "<h1>Clinic intake</h1>")

result = sb.deploy(
    name="clinic-intake",
    output_path="/workspace",
    entrypoint="index.html",
)
print(result["url"])
```

For full-stack/dynamic apps:

```python
result = sb.deploy(
    name="clinic-intake-api",
    output_path="/workspace",
    build_command="npm run build",
    run_command="npm start",
    port=3000,
    health_check_path="/",
    resources={
        "database": {"auto": True, "engine": "postgresql", "size": "xs"},
        "storage": {"auto": True},
    },
    domain="intake.apps.cliniciq.com",
)
```

If `result["state"] == "building"` for a dynamic app, poll
`miosa.deployments.get(result["deployment_id"])` until it is `running`. Always
display the server-returned `url` / `public_url`; do not hardcode
`preview.miosa.app`, `api.miosa.app`, or `<tenant>.miosa.app`.

To publish the exact snapshot that passed QA instead of whatever the editable
sandbox holds right now, use `deploy_snapshot`. It forks the snapshot into a
temporary release sandbox, deploys that fork, and destroys it again, so the
source sandbox is never mutated:

```python
snapshot = sb.snapshots.create("qa-approved")

result = sb.deploy_snapshot(
    snapshot["id"],
    name="clinic-intake",
    output_path="/workspace",
    entrypoint="index.html",
)
print(result["source_snapshot_id"], result["release_sandbox_id"])
```

The result always carries `source_snapshot_id` and `release_sandbox_id` for
provenance. Pass `cleanup=False` to keep the release sandbox for inspection.
If the release sandbox could not be destroyed, the deployment still succeeds and
`result["release_cleanup_error"]` explains why; when the deploy itself fails and
the release sandbox survives, the raised error carries the same detail so the
leftover sandbox can be cleaned up by id.

For workspace App Engine, publish from the same sandbox but choose the
App Engine target:

```python
result = sb.deploy_docker(
    name="lead-magnet",
    path="/workspace",
    build_command="npm run build",
    run_command="npm start",
    port=3000,
)
print(result.get("url") or result.get("public_url"))
```

App Engine runs app containers inside the workspace's dedicated MIOSA
App Engine appliance VM. It is separate from normal MIOSA dynamic runtime
VMs and is useful when a workspace needs many small apps, funnels, lead
magnets, APIs, or client sites.

## Desktop control

```python
screenshot = computer.screenshot()          # PNG bytes
computer.click(640, 400)
computer.double_click(640, 400)
computer.type("hello world")
computer.key("Return")
computer.scroll("down", 3)
computer.drag(100, 100, 400, 400)

cursor = computer.cursor()
windows = computer.windows()
computer.launch("firefox")
```

## Async usage

```python
import asyncio
from miosa import AsyncMiosa

async def main():
    async with AsyncMiosa(api_key="msk_live_...") as miosa:
        computer = await miosa.computers.create(
            name="async-build", template_type="miosa-sandbox"
        )
        await computer.files.write_file("/workspace/app.py", "print('async!')")
        result = await computer.exec.run("python /workspace/app.py")
        print(result.stdout)
        await computer.destroy()

asyncio.run(main())
```

## White-label / multi-tenant

Tag resources with `external_workspace_id` and `external_user_id` to attribute usage to your downstream customers:

```python
computer = miosa.computers.create(
    name="customer-build",
    template_type="miosa-sandbox",
    metadata={
        "external_workspace_id": "dental-office-123",
        "external_user_id": "dr-smith-456",
    },
)
```

White-label URL layers are separate:

```text
sandbox preview:     https://<port>-<slug>.sandbox.<preview-domain>
durable deployment:  https://<slug>.<deployment-domain>
custom domain:       https://app.customer.com
```

Tenant preview-domain management is available through `miosa.tenant`. Tenant
deployment-domain routing exists server-side; clients should consume deployment
`public_url` instead of reconstructing it.

## Runs

Use an idempotency key when a caller may retry run creation.
The same key prevents a retry from creating duplicate work.

```python
run = miosa.runs.run(
    sandbox_id="sbx_abc123",
    instruction="Build the ClinicIQ report",
    idempotency_key="clinic-iq-request-123",
)

events = miosa.runs.events(run["id"], limit=200)
for event in events:
    print(event["type"], event.get("metadata", {}))

# Continue from the last durable event without replaying earlier events.
new_events = miosa.runs.events(run["id"], after_id=events[-1]["id"])
```

The async client exposes the same contract through `await client.runs.run(...)` and `await client.runs.events(...)`.

## Environments

An environment is the template a new sandbox or computer inherits: which
repositories are cloned, which variables and secret files are injected, and
which of your credentials the machine may use. The same environments apply to
sandboxes and computers.

```python
# Create an environment and give it variables, a repo and a tool.
env = miosa.environments.create(
    "prod",
    variables={"STRIPE_KEY": "sk_live_..."},
    repositories=[{"repo": "octocat/hello-world", "setup_script": "npm ci"}],
    cli_tools={"gh": "latest"},
)

# Start a machine in it, with a one-off setup script and a create-time wait.
sb = miosa.sandboxes.create(
    "miosa-sandbox",
    environment="prod",
    setup_file="#!/bin/sh\nnpm ci\n",
    wait=45,  # wait up to 45s for ready; ?wait= on the create request itself
)

# One atomic save -> exactly one new version.
miosa.environments.update(
    "prod",
    variables_set={"REGION": "us-east-1"},
    variables_unset=["OLD"],
    pass_sandbox_credentials=True,
)

# Move pinned machines to the latest version.
miosa.environments.upgrade("prod")

# What a machine would receive, lowest layer first.
miosa.environments.effective("prod")
```

`miosa.environments.billing()` reports the account new machines bill, and
`miosa.deployment_environments.list()` is the deployment-environment inventory
(previously the deprecated `data` key on `GET /environments`).

## Snapshots

```python
# Account-wide history, newest first, searchable.
history = miosa.snapshot_history.list(q="builder", limit=50)

# Browse a snapshot's disk image without the machine.
tree = miosa.snapshot_history.tree("f3c1", path="/home/user")
blob = miosa.snapshot_history.file("f3c1", path="/home/user/notes.txt")

# Fork a snapshot into a new, independent machine.
fork = miosa.snapshot_history.fork("f3c1", idempotency_key="k1")

# Pin a snapshot under a name that outlives the machine.
miosa.named_snapshots.create("web-stack", sandbox_id="9a2e")
miosa.named_snapshots.deploy("web-stack")  # fork it into a new machine
```

## Commands and agent steering

```python
# The commands endpoint: synchronous, with cwd, stdin and a duration.
result = miosa.commands.run("sbx_abc123", "bash ./setup.sh", cwd="my-repo")
print(result["stdout"], result["exit_code"], result["duration_ms"])

# Steer the agent run attached to a machine.
miosa.agent_steering.prompt("sbx_abc123", "fix the failing test")
miosa.agent_steering.stop("sbx_abc123")
```

A machine that is not ready refuses a command with a retryable `409` →
`MachineStartingError`; poll it using `e.retry_after_ms`.

## Agents, connections and bill-to

```python
# The Agents tab: harnesses, model catalog, shared credentials.
harnesses = miosa.agent_settings.harnesses()
miosa.agent_settings.put_credential("openrouter", api_key="sk-or-...")
miosa.agent_settings.update_harness("claude-code", default_model="claude-sonnet-5-5")

# One read over model providers, apps and tool servers.
miosa.connections.list(family="models", usable_by="agents")

# Who pays for a create.
miosa.bill_to.update("acme")
limits = miosa.bill_to.limits()

# Triggers start runs on a schedule, a webhook, an event, Forge or email.
miosa.agent_triggers.create(
    "agent_1",
    name="Every Monday",
    kind="schedule",
    instruction="Summarize last week's tickets.",
    config={"cron": "0 9 * * 1", "timezone": "America/Chicago"},
)

# A chat's harness context and compaction.
miosa.agent_chats.context("chat_1")
miosa.agent_chats.compact("chat_1", focus="keep the API notes")
```

## Build a multi-tenant platform on MIOSA

Running a platform on MIOSA (the way ClinicIQ does) means every request belongs
to one of your customers. The pieces below are the whole story; a runnable
end-to-end version is `examples/multi_tenant_platform.py`.

### One key, many organizations

A platform API key is issued for one organization. To act for another, send
`X-MIOSA-Tenant` — set it once on the client, or per tenant with `with_tenant`:

```python
platform = Miosa(unbranded=True)          # no SDK branding in the headers
acme = platform.with_tenant("tenant_acme")  # every call now carries the header
```

Unbranded matters when your customers see your logs, not ours: `unbranded=True`
blanks the `User-Agent`, and `user_agent="acme-platform/1.0"` sets your own.

### Identity: stable references, readable names

Never mint a workspace slug per request. `POST /workspaces` resolves on
`external_workspace_id` before it inserts, so re-provisioning is idempotent:

```python
ws = platform.workspaces.create(name="@alex", external_workspace_id="cust_1041")
if ws.reused:
    print("existing workspace", ws.id)      # 200 reuse, nothing inserted
```

A live workspace that already carries the requested name raises
`WorkspaceNameExistsError` (409), carrying the existing workspace. Send
`allow_duplicate_name=True` when a second one is intended. Rename with
`workspaces.update(ws.id, name="@alex-b")`; the id and slug never change.

Sandbox and computer creates take the same attribution
(`external_workspace_id`, `external_user_id`, `external_project_id`), and
`metadata` and `tags` round-trip, so you can list your own customers' machines:

```python
platform.sandboxes.list(external_user_id="cust_1041")
client.sandboxes.list(tags=["user:cust_1041"])
```

### Scoped end-user tokens

Hand your customers a token that does one thing for one user, for an hour:

```python
token = platform.tokens.create_scoped(
    user_id="cust_1041",
    workspace_id=ws.id,
    expires_in_seconds=3600,
    scopes=["sandboxes:read", "sandboxes:write", "sandboxes:exec"],
)
platform.tokens.list_scoped()            # audit what is outstanding
platform.tokens.delete_scoped(token["id"])
```

Quotas bound what that user can spend:

```python
platform.quotas.set("cust_1041", max_sandboxes=10, max_concurrent=2,
                    max_storage_gb=50, max_credit_cents=5000)
platform.quotas.get("cust_1041")        # includes the effective policy
platform.quotas.delete("cust_1041")
```

### Isolation: environments with nothing of yours in them

`safe_for_third_parties` (or `no_env=True` on a create) guarantees a machine
receives none of the owner's credentials, whatever the toggles say. Egress is
capped with the network policy and the allowlist:

```python
env = platform.environments.create(
    "enduser-alex", workspace_id=ws.id, safe_for_third_parties=True
)
sandbox = platform.sandboxes.create("miosa-sandbox", environment=env["id"],
                                    workspace_id=ws.id, no_env=True)
sandbox.network.allow(host="api.stripe.com")
sandbox.network.lockdown(policy_id="pol_1")   # audit_only -> enforce
```

### Previews, shares and the desktop

```python
preview = sandbox.previews.create(port=3000, label="app")   # https://3000-<slug>...
share = sandbox.previews.share(preview["id"], ttl_seconds=3600)  # no key needed
platform.tenant.preview_domain.set("preview.acme.com")       # your own domain
platform.tenant.preview_domain.verify()                      # returns the DNS records
platform.custom_domains.create(domain="app.acme.com", deployment_id="dep_1")
computer.embed()                                             # iframe the desktop
computer.stream_token()                                      # short-lived desktop stream
```

### Who pays, and what it costs

A create bills the organization it runs in. Choose it per request without
touching anything:

```python
platform.sandboxes.create(..., bill_to="acme")   # or the X-Miosa-Bill-To header
platform.bill_to.update("acme")                  # account-wide default
platform.bill_to.limits()                        # before you start: credits, caps, reasons
platform.member_caps.update("tenant_acme", user_id, usage_cap_cents=500)
platform.usage.get(group_by="workspace_id", period="30d")   # usage for billing
platform.usage.get(external_user_id="cust_1041")            # one customer's usage
```

### SSH your customers can actually use

A sandbox's sshd is reached through the authenticated WebSocket tunnel, so the
SDK assembles the whole path. `client.ssh.ready()` reads `ssh-info`, signs a
short-lived certificate for your key and returns a runnable command:

```python
details = client.ssh.ready(
    sandbox.id, public_key, ttl_seconds=900, identity_file="~/.ssh/id_ed25519"
)
print(details["command"])
# ssh -o ProxyCommand=python3 -m miosa.ssh_tunnel %h -i ~/.ssh/id_ed25519 root@<sandbox id>

print(details["config"])        # a ~/.ssh/config block
print(details["known_hosts"])   # the known_hosts entry
```

The tunnel URL is host-derived, so a white-label platform sees its own host in
every snippet. For a non-expiring grant use `client.ssh.install_key(...)`.

The ProxyCommand is real: export `MIOSA_API_KEY` (and `MIOSA_BASE_URL` on your
own domain) and `ssh` works as-is. `python -m miosa.ssh_tunnel <sandbox id>` is
the bridge. From Python, `open_ssh_tunnel_socket(sandbox.id)` returns a
blocking socket for paramiko or asyncssh; see `examples/ssh_from_python.py`.
OpenComputers hosts get the same treatment with
`open_computers.hosts.ssh_keys(host_id)` / `add_ssh_key(host_id, key)`.

### API keys, every option

`api_keys.create()` forwards whatever the control plane accepts, so an option
added server-side works without an SDK change:

```python
key = client.api_keys.create(
    name="acme-backend",
    description="server key for acme",
    metadata={"team": "platform"},
    expires_at="2026-12-31T00:00:00Z",
    scopes=["sandboxes:read", "sandboxes:write"],
    workspace_id=ws.id,            # or project_id=
    ip_allowlist=["203.0.113.0/24"],
    rotation_overlap_days=7,
    preset="read_only",
)
client.api_keys.list()            # last_used_at and per-key usage come back
client.api_keys.rotate(key["id"])
client.api_keys.update(key["id"], scopes=["sandboxes:read"])
```

`api_keys.presets()`, `service_accounts` and the end-user `tokens.create_scoped`
complete the surface.

### Webhooks you can trust

Create the subscription, then verify every delivery:

```python
from miosa import verify_webhook_signature

hook = platform.webhooks.create(url="https://acme.com/hooks/miosa",
                                events=["run.finished", "deployment.released"])
secret = hook["secret"]

# in your receiver, with the RAW body:
if not verify_webhook_signature(raw_body, request.headers["X-MIOSA-Signature"], secret):
    abort(401)

platform.event_subscriptions.deliveries(subscription_id)
platform.event_subscriptions.replay_delivery(webhook_id, delivery_id)
```

### End-user apps on deployments

```python
dep = platform.deployments.create(name="acme-app", source={"type": "git", "url": repo})
platform.deployments.publish(dep.id)
platform.project_auth.enable(project_id)     # your customers log into the app
```

### Errors you will actually catch

`WorkspaceNameExistsError`, `TeamMemberCapReachedError`, `MemberLimitReachedError`,
`BillToLockedError`, `OwnCredentialsRequiredError`, `MachineStartingError` and the
rest carry the server's `code`, `details` and retry hints. See
[Error handling](#error-handling).

## Error handling

```python
from miosa import (
    AuthenticationError, NotFoundError, RateLimitError, MiosaError
)

try:
    computer = miosa.computers.get("cmp_doesnt_exist")
except NotFoundError:
    print("Computer not found")
except RateLimitError as e:
    print(f"Rate limited; retry after {e.retry_after}s")
except AuthenticationError:
    print("Check your API key")
except MiosaError as e:
    print(f"API error {e.status}: {e.message}")
```

Exception hierarchy: `MiosaError` > `AuthenticationError` (401), `InsufficientCreditsError` (402), `PermissionError` (403), `NotFoundError` (404), `ValidationError` (422), `RateLimitError` (429), `ServerError` (5xx), `ConnectionError`, `TimeoutError`.

Every error carries the server's stable `code` and any `details`, plus retry
hints: `retryable` (bool) and `retry_after_ms` (int, or the 429 `retry_after`).
The sprint codes have typed subclasses, so a caller can catch exactly what it
means:

```python
from miosa import (
    CreditRequiredError,        # 402 needing credit (a named snapshot over the free allowance)
    MachineStartingError,       # 409 SANDBOX_STARTING / COMPUTER_STARTING (retry)
    OwnCredentialsRequiredError,# 422 the harness has nothing of yours to run on
    RunSessionNotResumableError,# 409 continue_from_run_id cannot resume
    SnapshotInUseError,         # 409 a snapshot still has dependents
)

try:
    miosa.commands.run("sbx_abc123", "echo hi")
except MachineStartingError as e:
    time.sleep((e.retry_after_ms or 2000) / 1000)
```

The environments, snapshots, bill-to, agents and triggers codes each have a
typed class (`EnvironmentNotFoundError`, `EnvironmentMemoryWithheldError`,
`BillToLockedError`, `TeamMemberCapReachedError`, `UnknownHarnessError`,
`TriggerNotFoundError`, ...); see `miosa.errors` for the full list.

### Pagination

List endpoints page with `limit` plus `offset` or a cursor. `miosa.pagination`
drives that loop:

```python
from miosa import collect, paginate

for snapshot in paginate(
    lambda **p: client.snapshot_history.list(**p), items_key="data", limit=100
):
    print(snapshot["id"])

all_runs = collect(lambda **p: client.runs.list(**p), items_key="data")
```

`iter_cursor_pages` follows an opaque cursor; `iter_pages_async` and
`iter_cursor_pages_async` are the async twins.

### Retries

A `429` or `5xx` on an idempotent request (`GET`, `HEAD`, `OPTIONS`, `PUT`,
`DELETE`) is retried with exponential backoff, honoring `Retry-After`.
A `POST` or `PATCH` is retried only when it carries an `Idempotency-Key`, so a
retried create dedups server-side instead of running twice. `sandboxes.create()`
generates one for you; pass `idempotency_key=` to override, or add the header
yourself for another write.

## Configuration

| Option | Env var | Default |
|---|---|---|
| `api_key` | `MIOSA_API_KEY` | — |
| `base_url` | `MIOSA_BASE_URL` | `https://api.miosa.ai/api/v1` |
| `timeout` | — | 30s |
| `max_retries` | — | 3 |

## Links

- [Full documentation](https://miosa.ai/docs/sdks/python)
- [Quickstart](https://miosa.ai/docs/quickstart)
- [GitHub](https://github.com/Miosa-osa/miosa-python)
- [Contact](mailto:platform@miosa.ai)

## Phase 1-4 methods (v1.1.0)

**Preview tokens and share URLs**

```python
# Mint a scoped preview token (e.g. for iframe embed)
token = sb.preview_token(expires_in=3600, scope="read")
print(token["url"])  # https://...?mt=mp_<token>

# Create a public share link (no API key required)
share = sb.share.create(expires_in=7200)
print(share["share_url"])
```

**File tree and batch write**

```python
tree = sb.files.tree("/workspace", depth=2)
sb.files.write_many([
    {"path": "/workspace/app.py", "content": "print('hello')"},
    {"path": "/workspace/cfg.json", "content": b"{}"},
])
```

**Tenant events stream and quotas**

```python
for event in client.events.stream(types=["sandbox.*"]):
    print(event["_event_type"], event)

client.quotas.set("usr_abc", max_sandboxes=10, max_concurrent=3)
```

**Webhook signature verification**

```python
from miosa.resources.webhooks import Webhooks

ok = Webhooks.verify_signature(request.body, request.headers["X-Miosa-Signature"], secret)
```

## License

MIT
