From 816410210bc961c7b7e0f944ad1068e066ccfc59 Mon Sep 17 00:00:00 2001 From: Mandalorian-Wang <275727085+Mandalorian-Wang@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:58:49 +0800 Subject: [PATCH 1/3] docs: generate llms.txt instead of maintaining it by hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I deleted the BoxLite Cloud section from llms.txt in ffe3d36. The sync that landed that commit deliberately excluded `cloud/` and `docs.json` because both held content the source tree could not produce — but llms.txt is derived from the whole page set the same way, and I did not think of it, so the source-tree version overwrote it and took all 21 Cloud lines with it. Restoring those lines by hand would leave the underlying problem in place. llms.txt duplicates two facts that already live elsewhere: the navigation tree (docs.json) and each page's title and description (its own frontmatter). A hand-maintained third copy rots silently, and this one had: * five dead links — manage-sandbox/configuration, guides/production-best-practices, guides/building-from-source, guides/macos-sandbox-debugging, and architecture/internals, all renamed, moved, or deleted in earlier passes * group names from a superseded IA ("Manage Sandbox", "Guides", "Resources" against the live "Manage sandboxes", "From demo to production", "FAQ and releases") * the three use-case mode groups, flattened in the site long ago * no BoxLite Cloud section scripts/gen-llms-txt.py now derives the file from docs.json plus frontmatter. 85 entries, matching the 85 pages in the navigation, with no dead links. `--check` is wired into the docs-lint workflow so staleness fails CI rather than waiting for a reader to notice. Co-Authored-By: Claude Opus 5 --- .github/workflows/docs-lint.yml | 2 + llms.txt | 175 ++++++++++++++++++-------------- scripts/gen-llms-txt.py | 87 ++++++++++++++++ 3 files changed, 185 insertions(+), 79 deletions(-) create mode 100644 scripts/gen-llms-txt.py diff --git a/.github/workflows/docs-lint.yml b/.github/workflows/docs-lint.yml index cea05d2..88e75ed 100644 --- a/.github/workflows/docs-lint.yml +++ b/.github/workflows/docs-lint.yml @@ -15,3 +15,5 @@ jobs: python-version: "3.12" - name: No soft promises (AGENTS.md) run: python3 scripts/lint-docs.py . + - name: llms.txt matches docs.json and page frontmatter + run: python3 scripts/gen-llms-txt.py --check diff --git a/llms.txt b/llms.txt index 926f3d7..d63c9c7 100644 --- a/llms.txt +++ b/llms.txt @@ -4,106 +4,123 @@ ## BoxLite Opensource -### Getting Started -- [BoxLite documentation](/index.mdx) +### Getting started +- [BoxLite documentation](/index.mdx): Run arbitrary code, commands, browsers, desktops, or entire AI agents inside hardware-isolated microVM sandboxes that start in about a secon… - [Introduction to BoxLite](/getting-started/index.mdx): What a Box actually is, how it differs from a container, and which of its capabilities are ready to build on today. - [Installation](/getting-started/installation.mdx): Install the four SDKs and the CLI, and confirm your machine meets the virtualization requirement. -- [Python quickstart](/getting-started/quickstart-python.mdx): The shortest path to running your first code in an isolated microVM sandbox: pull an image, start a Box, run a command,… +- [Python quickstart](/getting-started/quickstart-python.mdx): The shortest path to running your first code in an isolated microVM sandbox: pull an image, start a Box, run a command, and read the result… - [Node.js quickstart](/getting-started/quickstart-nodejs.mdx): From npm install to a command running inside an isolated microVM, in about five minutes. -- [Rust quickstart](/getting-started/quickstart-rust.mdx) -- [C quickstart](/getting-started/quickstart-c.mdx): Start a sandbox and run a command from C, using the Simple API for the happy path and the Native API when you need… - -### Manage Sandbox -- [Manage sandboxes](/manage-sandbox/index.mdx) -- [Box types](/manage-sandbox/sandbox-types.mdx): Six one-line constructors, each returning an isolated environment tuned for one job: commands, code, a browser, a… -- [Lifecycle](/manage-sandbox/lifecycle.mdx): Manage sandboxes as long-lived resources — create, start, stop, remove, plus detach to survive the parent process and… -- [Custom configuration](/manage-sandbox/configuration.mdx): Shape a sandbox's runtime without building a new image: environment variables, entrypoint and command, run user,… +- [Rust quickstart](/getting-started/quickstart-rust.mdx): The shortest path to running your first command in an isolated microVM sandbox from Rust: streaming output and an exit code, with no virtual… +- [Go quickstart](/getting-started/quickstart-go.mdx): The shortest path to running your first command in an isolated microVM sandbox from Go: a real exit code and stdout, with no virtual machine… +- [C quickstart](/getting-started/quickstart-c.mdx): Start a sandbox and run a command from C, using the Simple API for the happy path and the Native API when you need streaming or fine-grained… + +### Manage sandboxes +- [Manage sandboxes](/manage-sandbox/index.mdx): A Box is a disposable microVM that boots in about a second. The Boxlite runtime creates, lists, and removes boxes; a Box handle only execute… +- [Box types](/manage-sandbox/sandbox-types.mdx): Six one-line constructors, each returning an isolated environment tuned for one job: commands, code, a browser, a desktop, an interactive te… +- [Lifecycle](/manage-sandbox/lifecycle.mdx): The states a box moves through — created, running, stopped, removed — and what survives each transition. +- [Environment and startup](/manage-sandbox/environment.mdx): Shape the Linux environment inside a box — variables, run user, working directory — and control what it starts: the entrypoint, the command,… - [Compute resources](/manage-sandbox/compute-resources.mdx): Allocate CPU (cpus), memory (memory_mib), and disk (disk_size_gb) so a workload is neither starved nor wasteful. - [Volumes and mounts](/manage-sandbox/volumes.mdx): Mount a host directory into the sandbox so both sides read and write the same files. -- [Network access](/manage-sandbox/network-access.mdx): Control the sandbox's network boundary: expose a service to the host with port forwarding, restrict outbound traffic… +- [Network access](/manage-sandbox/network-access.mdx): Control the sandbox's network boundary: expose a service to the host with port forwarding, restrict outbound traffic with an egress allowlis… - [Snapshots and clones](/manage-sandbox/snapshots.mdx): Save a sandbox's disk state, create a fully independent copy, or pack an entire sandbox into a portable archive. -- [Secrets and security isolation](/manage-sandbox/secrets-and-security.mdx): Deliver credentials to a sandbox without letting the sandbox see them, and tighten the isolation boundary so untrusted… +- [Inject secrets and harden a box](/manage-sandbox/secrets-and-security.mdx): Deliver credentials to a sandbox without letting the sandbox see them, and tighten the isolation boundary so untrusted code runs safely. -### Agent Tools -- [Agent tools](/agent-tools/index.mdx): The capabilities an agent calls inside an isolated sandbox: run commands, run generated code, move files, and drive a… -- [Python code execution](/agent-tools/code-execution-python.mdx): Run Python inside an isolated microVM with CodeBox and read stdout back — the shortest path for an agent's \"write… -- [Run any language / command](/agent-tools/code-execution-any-language.mdx): Run any shell command or executable — in any language — inside a disposable microVM, and read back a structured stdout… -- [Pseudo-terminal (PTY)](/agent-tools/pseudo-terminal.mdx) +### Agent tools +- [Agent tools](/agent-tools/index.mdx): The capabilities an agent calls inside an isolated sandbox: run commands, run generated code, move files, and drive a terminal, desktop, or… +- [Run Python code in a box](/agent-tools/code-execution-python.mdx): Run Python inside an isolated microVM with CodeBox and read stdout back — the shortest path for an agent's "write code, execute, read result… +- [Run any language / command](/agent-tools/code-execution-any-language.mdx): Run any shell command or executable — in any language — inside a disposable microVM, and read back a structured stdout / stderr / exit code. +- [Interactive shell (PTY)](/agent-tools/pseudo-terminal.mdx): InteractiveBox opens a real interactive terminal inside a sandbox, like docker exec -it — every keystroke reaches the shell or REPL in the b… - [Computer use (desktop)](/agent-tools/computer-use.mdx): Boot a full Linux desktop inside a sandbox so an agent can see the screen, move the mouse, click, type, and scroll. -- [Browser automation](/agent-tools/browser-automation.mdx) -- [Drive a sandbox from your agent loop](/agent-tools/drive-from-agent-loop.mdx): Give your agent one safe door to a real machine: the model proposes a command, your loop runs it in a microVM, and the… -- [MCP server](/agent-tools/mcp-server.mdx): Expose sandboxed command execution to any Model Context Protocol (MCP) client by wrapping BoxLite in a small MCP server… -- [GitHub operations](/agent-tools/github-operations.mdx): Let an agent clone, commit, push, and open pull requests from inside a sandbox — with your GITHUB_TOKEN never entering… - -### Agent in a Box -- [Agent in box](/agent-in-box/index.mdx): Run a complete agent — its file access, command execution, and package installs — inside a microVM instead of on your… -- [Run Claude Code](/agent-in-box/run-claude-code.mdx): Install and drive the Claude Code CLI inside a microVM, where it can read, write, execute, and install freely without… -- [Run Codex](/agent-in-box/run-codex.mdx): Install and drive the OpenAI Codex CLI inside a microVM, so the agent can execute freely while the blast radius stays… -- [Run Pi](/agent-in-box/run-pi.mdx): Install and drive the Pi coding agent inside a microVM, where it can read, write, and execute freely while your host… -- [Run OpenCode](/agent-in-box/run-opencode.mdx): Install and drive the OpenCode CLI inside a microVM, configuring its provider without writing a config file into the… - -### Human Tools +- [Browser automation](/agent-tools/browser-automation.mdx): BrowserBox runs a real Chromium, Firefox, or WebKit inside an isolated microVM and exposes a WebSocket endpoint — your Playwright script sta… +- [Drive a sandbox from your agent loop](/agent-tools/drive-from-agent-loop.mdx): Give your agent one safe door to a real machine: the model proposes a command, your loop runs it in a microVM, and the result goes back into… +- [Wrap a sandbox as an MCP tool handler](/agent-tools/mcp-server.mdx): Expose sandboxed command execution to any Model Context Protocol (MCP) client by wrapping BoxLite in a small MCP server of your own. +- [GitHub operations](/agent-tools/github-operations.mdx): Let an agent clone, commit, push, and open pull requests from inside a sandbox — with your GITHUB_TOKEN never entering it. + +### Agent in a box +- [Agent in a box](/agent-in-box/index.mdx): Run a complete agent — its file access, command execution, and package installs — inside a microVM instead of on your host. +- [Run Claude Code](/agent-in-box/run-claude-code.mdx): Install and drive the Claude Code CLI inside a microVM, where it can read, write, execute, and install freely without touching the host. +- [Run Codex](/agent-in-box/run-codex.mdx): Install and drive the OpenAI Codex CLI inside a microVM, so the agent can execute freely while the blast radius stays inside a disposable VM… +- [Run Pi](/agent-in-box/run-pi.mdx): Install and drive the Pi coding agent inside a microVM, where it can read, write, and execute freely while your host and your provider key s… +- [Run OpenCode](/agent-in-box/run-opencode.mdx): Install and drive the OpenCode CLI inside a microVM, configuring its provider without writing a config file into the image. +- [Run Hermes](/agent-in-box/run-hermes.mdx): Run the Hermes agent inside a microVM using its official image — one-shot prompts, or the messaging gateway behind a forwarded port. + +### Human tools - [Human tools](/human-tools/index.mdx): Inspect or take over a sandbox's graphical interface from your browser — no VNC client to install. -- [Desktop / VNC access](/human-tools/desktop-access.mdx): Share a Linux desktop running inside a sandbox with a human, through a browser and with no VNC client. -- [Browser access](/human-tools/browser-access.mdx): Run a real browser inside an isolated sandbox and drive it from the host with Playwright or Puppeteer. - -### Guides -- [Agent builder guide](/guides/index.mdx) -- [Production best practices](/guides/production-best-practices.mdx): A checklist for running sandboxes in production: concurrency, resource ceilings, isolation, and guaranteed cleanup. -- [Deployment](/guides/deployment.mdx): Run a BoxLite-backed service inside Docker or on Kubernetes, where each sandbox still needs hardware virtualization. -- [Agent service endpoint (REST)](/guides/agent-service-endpoint.mdx): Manage a fleet of sandboxes through one REST endpoint — your code needs only a URL and an API key, not local… +- [Share a desktop with a person in a browser](/human-tools/desktop-access.mdx): Share a Linux desktop running inside a sandbox with a human, through a browser and with no VNC client. +- [Inspect an in-box browser from your DevTools](/human-tools/browser-access.mdx): Attach your own Chrome DevTools to a browser running inside a sandbox, to watch or drive it by hand. + +### From demo to production +- [From demo to production](/guides/index.mdx): Production means four things at once: every agent session gets its own disposable microVM, tool calls stay sandboxed, resources are bounded,… - [Error handling](/guides/error-handling.mdx): Tell command failure, timeout, parse failure, and low-level runtime errors apart — and recover from each separately. -- [Image registry configuration](/guides/image-registry-configuration.mdx): Pull agent images from private or custom OCI registries — a corporate registry, ghcr.io, quay.io, or a local caching… -- [Building from source](/guides/building-from-source.mdx): From git clone to a working local SDK, using the repository's own make targets. -- [macOS sandbox debugging](/guides/macos-sandbox-debugging.mdx): Use this page when a box fails to start on macOS and you suspect Seatbelt. BoxLite runs boxlite-shim under a… +- [Running sandboxes at scale](/guides/at-scale.mdx): What breaks when you go from one sandbox to many, long-lived ones — and the four controls that keep it from breaking. +- [Deploy in Docker or Kubernetes](/guides/deployment.mdx): Run a BoxLite-backed service inside Docker or on Kubernetes, where each sandbox still needs hardware virtualization. +- [Manage remote sandboxes over REST](/guides/agent-service-endpoint.mdx): Manage a fleet of sandboxes through one REST endpoint — your code needs only a URL and an API key, not local virtualization. +- [Image registry configuration](/guides/image-registry-configuration.mdx): Pull agent images from private or custom OCI registries — a corporate registry, ghcr.io, quay.io, or a local caching proxy — instead of only… ### Architecture -- [Architecture overview](/architecture/index.mdx): The components a box.exec(...) call passes through, where the isolation boundary sits, and how each SDK maps onto the… -- [Under the hood](/architecture/internals.mdx): What BoxLite does inside your process when you run async with SimpleBox(...) — enough of a mental model to tune… +- [Architecture overview](/architecture/index.mdx): The components a box.exec(...) call passes through, where the isolation boundary sits, and how each SDK maps onto the Rust core. - [Core components](/architecture/core-components.mdx): What each module is responsible for, where its state lives on disk, and which metrics it exposes. -- [Security and isolation](/architecture/security-and-isolation.mdx) -- [Networking](/architecture/networking.mdx): How a box reaches the network: the user-mode stack, the fixed subnet, DNS handling, and how a host port reaches a… +- [Security and isolation](/architecture/security-and-isolation.mdx): How BoxLite's isolation is layered against a guest assumed malicious from the moment it starts, and which layer each SecurityOptions field c… +- [Networking](/architecture/networking.mdx): How a box reaches the network: the user-mode stack, the fixed subnet, DNS handling, and how a host port reaches a service inside the box. - [Storage](/architecture/storage.mdx): Where a box's bytes live: the digest-keyed image cache, the copy-on-write rootfs, and the two kinds of mount. - [Boot latency analysis](/architecture/boot-latency.mdx): Where the time goes between start() and a ready container — and why the second start is far faster than the first. -### SDK Reference -- [SDK reference](/reference/index.mdx): Index of the BoxLite SDKs and a capability comparison across the four language bindings (Python / Node.js / Rust / C).… -- [Python SDK reference](/reference/python.mdx): Complete class / method / parameter / return / exception reference. All signatures and defaults follow the latest… -- [Node.js SDK reference](/reference/nodejs.mdx): The complete API surface of @boxlite-ai/boxlite: runtime, box handle, box types, errors, metrics, and the errors people… -- [Rust SDK reference](/reference/rust.mdx): **What and why**: the boxlite crate is the core implementation of BoxLite — a Tokio-based, async-first library for… -- [C SDK reference](/reference/c.mdx): **What and why**: BoxLite's C SDK embeds hardware-isolated microVM sandboxes into any C/C++ program, game engine, or… -- [CLI reference](/reference/cli.mdx): **What and why**: the boxlite command line offers a docker-like experience for creating, running, and managing isolated… - -### Resources -- [Resources](/resources/index.mdx): Reference material that sits alongside the documentation: answers to recurring questions, what changed between… +### SDK reference +- [SDK reference](/reference/index.mdx): One Rust runtime behind five bindings — Python, Node.js, Rust, Go, and C. Their capabilities are **not fully aligned**, so this is where you… +- [Python SDK reference](/reference/python.mdx): Complete class / method / parameter / return / exception reference. All signatures and defaults follow the latest published BoxLite source (… +- [Node.js SDK reference](/reference/nodejs.mdx): The complete API surface of @boxlite-ai/boxlite: runtime, box handle, box types, errors, metrics, and the errors people hit most. +- [Rust SDK reference](/reference/rust.mdx): the boxlite crate is the core implementation of BoxLite — a Tokio-based, async-first library for creating, running, and destroying hardware-… +- [Go SDK reference](/reference/go.mdx): github.com/boxlite-ai/boxlite/sdks/go is a CGO binding over the same Rust core every other SDK uses. It has no high-level wrapper like Pytho… +- [C SDK reference](/reference/c.mdx): BoxLite's C SDK embeds hardware-isolated microVM sandboxes into any C/C++ program, game engine, or other language's FFI layer through a stab… +- [CLI reference](/reference/cli.mdx): the boxlite command line offers a docker-like experience for creating, running, and managing isolated lightweight microVM sandboxes from the… + +### FAQ and releases +- [FAQ and releases](/resources/index.mdx): Reference material that sits alongside the documentation: answers to recurring questions, and what changed between releases. - [FAQ](/faq.mdx): Short answers to what teams ask most when adopting BoxLite, with copy-runnable snippets. -- [Changelog](/guides/changelog.mdx): Where BoxLite's release history lives, and how to check which version you are running. -- [BoxLite contributor license agreement](/legal/CLA.mdx) +- [Release notes and your version](/guides/changelog.mdx): Where BoxLite's release history lives, and how to check which version you are running. ### Contributing -- [Contributing](/development/index.mdx): Documentation for people working on BoxLite itself rather than building on it: how to develop the CLI, how to run the… +- [Contributing](/development/index.mdx): Documentation for people working on BoxLite itself rather than building on it: how to develop the CLI, how to run the tests that boot real m… - [CLI development guide](/development/cli-development.mdx): Build, test, and extend the boxlite command — implemented in the boxlite-cli crate under src/cli/. -- [Local E2E](/development/e2e-local.mdx): An operations runbook for the VM integration tests, in CI and on your own machine. -- [Rust style guide](/development/rust-style.mdx): Coding conventions for contributors to the BoxLite core (the Rust crates). The goal is for submitted PRs to pass the CI… -- [Concurrent execution deadlock investigation](/development/concurrent-exec-deadlock.mdx): **Resolved and superseded.** A historical record of one deadlock — kept for the debugging method, not as current… +- [VM integration tests, locally and in CI](/development/e2e-local.mdx): An operations runbook for the VM integration tests, in CI and on your own machine. +- [Rust style guide](/development/rust-style.mdx): Coding conventions for contributors to the BoxLite core (the Rust crates). The goal is for submitted PRs to pass the CI cargo fmt / cargo cl… +- [Building from source](/development/building-from-source.mdx): From git clone to a working local SDK, using the repository's own make targets. +- [Debug macOS Seatbelt denials](/development/macos-sandbox-debugging.mdx): On macOS, BoxLite runs boxlite-shim under a deny-by-default sandbox-exec (Seatbelt) policy. When a rule denies an operation, the box fails t… +- [Concurrent execution deadlock investigation](/development/concurrent-exec-deadlock.mdx): **Resolved and superseded.** A historical record of one deadlock — kept for the debugging method, not as current behaviour. +- [BoxLite contributor license agreement](/legal/CLA.mdx) + +## BoxLite Cloud + +### Getting started +- [What is BoxLite Cloud](/cloud/index.mdx): A hosted agent runtime built on BoxLite — the same SDK and the same core API, reached with a URL and an API key, with managed storage and a… +- [BoxLite Cloud vs open source](/cloud/vs-opensource.mdx): Every difference between self-hosted BoxLite and BoxLite Cloud — entry point, auth, exec shape, storage, lifecycle, images, and billing — pl… +- [Run untrusted code on BoxLite Cloud](/cloud/quickstart.mdx): Create an API key, point the SDK at BoxLite Cloud, and run a command inside a hardware-isolated box — no local virtualization, no daemon, th… +- [API keys and authentication](/cloud/api-keys.mdx): Create a BoxLite Cloud API key, hand it to the SDK, CLI, or curl through the environment, and rotate it without downtime. + +### Boxes +- [Configure a box on BoxLite Cloud](/cloud/boxes.mdx): Pick an image and a size for a Cloud box, and understand the three lifecycle controls the platform applies while it runs. + +### Volumes +- [Persistent volumes on BoxLite Cloud](/cloud/volumes.mdx): Create a managed volume from the SDK or the console, mount it into a box by id, and keep the data after the box is gone. + +### Network +- [Reach a service running inside a box](/cloud/network.mdx): Open a tunnel to a port inside a Cloud box to reach an HTTP server, a WebSocket endpoint, or any TCP service — and understand the box's outb… +- [Control inbound and outbound access to a box](/cloud/network-policy.mdx): Decide who may reach a Cloud box — keep it private, share one port through a signed link, or make it public — and set what the box itself is… + +### Billing +- [Plans, wallet, and usage](/cloud/billing.mdx): How BoxLite Cloud charges for boxes: a plan gives you included quota and a concurrency limit each cycle, and your prepaid wallet funds every… ## Use cases ### Use cases -- [Use cases](/use-cases/index.mdx): Complete, end-to-end guides for shipping something real with BoxLite. Each one takes a scenario from problem statement… - -### The box is a tool the model calls -- [Build a code interpreter for your LLM](/use-cases/code-interpreter.mdx): Let a model write Python freely and execute it inside a hardware-isolated microVM. You get the result; your host never… -- [Build a data analysis agent](/use-cases/data-analysis-agent.mdx): Hand a CSV to a model, let it write its own pandas and matplotlib code, and run that code in an isolated microVM. You… -- [Preview a sandboxed web app](/use-cases/sandboxed-web-app.mdx): Run a whole web service — generated by a model or uploaded by a user — inside an isolated microVM, and reach its HTTP… -- [Expose a sandbox as an MCP tool](/use-cases/mcp-tool-server.mdx): Wrap an isolated sandbox in a standard MCP server so Claude Desktop, an IDE, or any agent can discover and call it over… -- [Review untrusted pull requests in a sandbox](/use-cases/sandboxed-ci.mdx): Run an outside contributor's pull request — install its dependencies, execute its tests, and have a model review it —… - -### The agent lives in the box -- [Give an agent a computer](/use-cases/computer-use-agent.mdx): Run a full Linux desktop inside an isolated microVM and let a vision model drive it — look at the screen, decide, move… -- [Scrape and analyze the web safely](/use-cases/web-scraping-agent.mdx): Drive a real browser inside a microVM, parse the untrusted HTML it returns in a *second* sandbox, and only then hand… -- [Use a custom image as the agent environment](/use-cases/custom-agent-image.mdx): Give your agent the toolchain it needs on the first boot instead of installing it every time. Any OCI image — a public… - -### Multi-tenant platform -- [Run an interactive development environment](/use-cases/interactive-dev-environment.mdx): Give each user, branch, or agent a live shell in its own microVM — cd, install, edit, test, with state surviving… -- [Run untrusted tools safely](/use-cases/untrusted-tool-execution.mdx): Execute a scanner, a parser, or any third-party binary against a sample you did not write — with the network off,… +- [Use cases](/use-cases/index.mdx): Complete, end-to-end guides for shipping something real with BoxLite. Each one takes a scenario from problem statement to running code, stat… +- [Build a code interpreter for your LLM](/use-cases/code-interpreter.mdx): Let a model write Python freely and execute it inside a hardware-isolated microVM. You get the result; your host never runs the untrusted co… +- [Build a data analysis agent](/use-cases/data-analysis-agent.mdx): Hand a CSV to a model, let it write its own pandas and matplotlib code, and run that code in an isolated microVM. You get the answer and the… +- [Preview a sandboxed web app](/use-cases/sandboxed-web-app.mdx): Run a whole web service — generated by a model or uploaded by a user — inside an isolated microVM, and reach its HTTP endpoints from the hos… +- [Expose a sandbox as an MCP tool](/use-cases/mcp-tool-server.mdx): Wrap an isolated sandbox in a standard MCP server so Claude Desktop, an IDE, or any agent can discover and call it over the protocol — no pe… +- [Review untrusted pull requests in a sandbox](/use-cases/sandboxed-ci.mdx): Run an outside contributor's pull request — install its dependencies, execute its tests, and have a model review it — inside a disposable mi… +- [Give an agent a computer](/use-cases/computer-use-agent.mdx): Run a full Linux desktop inside an isolated microVM and let a vision model drive it — look at the screen, decide, move the mouse, type. A mi… +- [Scrape and analyze the web safely](/use-cases/web-scraping-agent.mdx): Drive a real browser inside a microVM, parse the untrusted HTML it returns in a *second* sandbox, and only then hand clean structured data t… +- [Use a custom image as the agent environment](/use-cases/custom-agent-image.mdx): Give your agent the toolchain it needs on the first boot instead of installing it every time. Any OCI image — a public one, your team's stan… +- [Give each user a persistent shell](/use-cases/interactive-dev-environment.mdx): Give each user, branch, or agent a live shell in its own microVM — cd, install, edit, test, with state surviving between commands. A persona… +- [Run untrusted tools safely](/use-cases/untrusted-tool-execution.mdx): Execute a scanner, a parser, or any third-party binary against a sample you did not write — with the network off, privileges dropped, and th… diff --git a/scripts/gen-llms-txt.py b/scripts/gen-llms-txt.py new file mode 100644 index 0000000..c00a32e --- /dev/null +++ b/scripts/gen-llms-txt.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Generate llms.txt from docs.json and each page's own frontmatter. + +Why generated rather than hand-maintained: llms.txt duplicates two facts that +already live elsewhere — the navigation tree (docs.json) and each page's title +and description (its frontmatter). Maintaining a third copy by hand means it +silently rots. When this script was written the committed llms.txt had five +dead links (`manage-sandbox/configuration`, `guides/production-best-practices`, +`guides/building-from-source`, `guides/macos-sandbox-debugging`, +`architecture/internals` — all renamed, moved, or deleted in earlier passes), +group names from a superseded IA, and no BoxLite Cloud section at all. + +Usage: + python3 scripts/gen-llms-txt.py # write llms.txt + python3 scripts/gen-llms-txt.py --check # exit 1 if llms.txt is stale + +The --check mode is what CI should run: it makes staleness a build failure +instead of something a reader discovers. +""" + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DESCRIPTION_LIMIT = 140 # keep one entry to one readable line + +HEADER = """# BoxLite Documentation + +> BoxLite is an embeddable microVM sandbox for AI agents — stateful, sub-second boot, hardware-level isolation, no daemon required. +""" + + +def frontmatter(route: str) -> tuple[str, str]: + """(title, description) from a page's own frontmatter — the single source.""" + path = ROOT / f"{route}.mdx" + if not path.exists(): + raise FileNotFoundError(f"docs.json lists {route} but {path.name} is missing") + text = path.read_text(encoding="utf-8") + if not text.startswith("---"): + return route, "" + block = text.split("---", 2)[1] + def field(name: str) -> str: + m = re.search(rf'^{name}:\s*"(.*)"\s*$', block, re.M) + return (m.group(1) if m else "").replace('\\"', '"') + title = field("title") or route + description = field("description") + if len(description) > DESCRIPTION_LIMIT: + description = description[:DESCRIPTION_LIMIT].rstrip() + "…" + return title, description + + +def build() -> str: + nav = json.loads((ROOT / "docs.json").read_text(encoding="utf-8"))["navigation"] + out = [HEADER] + for tab in nav["tabs"]: + out.append(f"## {tab['tab']}\n") + for group in tab["groups"]: + out.append(f"### {group['group']}") + routes = ([group["root"]] if "root" in group else []) + group["pages"] + for route in routes: + title, description = frontmatter(route) + suffix = f": {description}" if description else "" + out.append(f"- [{title}](/{route}.mdx){suffix}") + out.append("") + return "\n".join(out).rstrip() + "\n" + + +def main() -> int: + generated = build() + target = ROOT / "llms.txt" + if "--check" in sys.argv: + current = target.read_text(encoding="utf-8") if target.exists() else "" + if current != generated: + print("llms.txt is stale. Run: python3 scripts/gen-llms-txt.py") + return 1 + print("llms.txt is up to date.") + return 0 + target.write_text(generated, encoding="utf-8") + entries = generated.count("\n- [") + print(f"wrote llms.txt: {entries} entries") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 107f48348833fcc00b7c7690cc42d4aefd999d51 Mon Sep 17 00:00:00 2001 From: Mandalorian-Wang <275727085+Mandalorian-Wang@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:10:22 +0900 Subject: [PATCH 2/3] docs: rewrite Cloud volumes against main, split operations, fix tab name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The volumes page described an API that no longer exists. It was written against a checkout 147 commits behind origin/main, and volume support changed substantially in between. Verified against boxlite-sdk-main at d354470a, level with origin/main: * `VolumeInfo` carries a `name`, and a volume mounts by name or by id (volumes/store.rs:22-25). The page said `create()` took no arguments and that the id was the only handle. * An entire section, "Names live in the console, ids come from the SDK", argued a product difference that no longer exists. The console and the SDK now name volumes the same way. Removed. * The wire field is `managed_volume`; the `volume://` scheme has no remaining reference anywhere in the tree. * Host bind mounts are rejected before any network request goes out (rest/runtime.rs:167-177), not silently ignored as the page warned. The real error string is now quoted. * Read-only managed volumes are refused client-side with a specific message rather than becoming a server 400. * `size_bytes` is always empty: neither server response builder returns a size field (boxlite-volume.controller.ts:66-81). It was documented as a working field. * Mount paths are validated server-side — absolute, not root, no relative components, no consecutive slashes, no system directories. None of this was documented; there is now a table. Structure follows the progressive-disclosure order the style guide requires, which the previous version did not: Prerequisites, then a runnable Quick example, then concepts, then the parameter tables. The first code block a reader met used to be a fragment with undefined variables, four screens above anything runnable. Four sibling Cloud pages had a Prerequisites section; this one did not. Volume state and deletion move to cloud/volume-operations. They serve a different reader — someone whose volume is already carrying data — and the deletion poll depends on the `state` field the neighbouring section explains, so the two travel together. Troubleshooting rows that moved are not duplicated; the origin page keeps one pointer instead. This also fills the Volumes nav group, which shipped with an empty `pages` array and rendered as a chevron that expanded to nothing. Tab renamed from "BoxLite Opensource" to "BoxLite Open Source". "Opensource" is not a word, and it was the only place in the tree using that spelling: the seven other occurrences are all the `/cloud/vs-opensource` route slug, and prose already used `open source` and `open-source` correctly throughout. Co-Authored-By: Claude Opus 5 --- cloud/volume-operations.mdx | 188 +++++++++++++++ cloud/volumes.mdx | 463 +++++++++++++++++++++++++----------- docs.json | 6 +- llms.txt | 5 +- 4 files changed, 525 insertions(+), 137 deletions(-) create mode 100644 cloud/volume-operations.mdx diff --git a/cloud/volume-operations.mdx b/cloud/volume-operations.mdx new file mode 100644 index 0000000..22fe64a --- /dev/null +++ b/cloud/volume-operations.mdx @@ -0,0 +1,188 @@ +--- +title: "Operating volumes on BoxLite Cloud" +sidebarTitle: "Volume operations" +description: "Read the volume state the SDK does not expose, and delete a volume knowing that reclamation is asynchronous." +--- + +Once a volume is carrying data, two questions come up that creating and mounting never raise: **what state is this volume actually in**, and **what happens when I delete it**. Both answers live over REST rather than in the SDK. + +If you are still setting a volume up, start with [Volumes](/cloud/volumes) instead. + +## Prerequisites + +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- The REST URL exported as `BOXLITE_REST_URL`, and a volume you already created. See [Volumes](/cloud/volumes). + +## What the SDK does not tell you + +Two pieces of volume state reach the REST API but stop at the SDK. Read them over REST when you need them. + +| What you want | SDK | REST | +|---|---|---| +| Whether a volume is ready, creating, or being deleted | Not exposed on `VolumeInfo` | `state` on `GET /v1/volumes` and `GET /v1/volumes/{id}` | +| Why a volume failed | Not exposed on `VolumeInfo` | `error_reason` on `GET /v1/volumes/{id}` | +| Volume size | `size_bytes` / `sizeBytes` is **always empty** | Not reported either — the service does not return a size field | + +`state` is one of `creating`, `ready`, `pending_create`, `pending_delete`, `deleting`, `deleted`, or `error`. + +```bash +# Read the state the SDK does not surface. +curl -fsS "${BOXLITE_REST_URL}/v1/volumes/${VOLUME_ID}" \ + -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ + | jq '{id, name, state, error_reason}' +``` + +Creating a volume over REST already waits for it to become ready before returning, so a volume you just created from the SDK or from `POST /v1/volumes` is mountable. Poll `state` when you are adopting a volume you did not just create, or when you are diagnosing one that is not behaving. +## Deletion is asynchronous + +Removing a volume returns an acknowledgement, not a completed deletion — `remove()` returns nothing and `DELETE /v1/volumes/{volume_id}` returns `204`. Immediately afterwards: + +- A REST read of that volume returns `200` with a `state` of `pending_delete` — **not** a `404`. +- A listing can still include the volume for a short window. +- Reclamation finishes on the platform's own cycle. + +So do not write code that waits for a `404`. Poll with a bounded timeout, and treat a volume that has left the listing as done: + + + +```python Python +# cloud_volume_delete.py — remove a volume, then wait for it to leave the listing +# Run: python cloud_volume_delete.py +import asyncio +import os + +from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions + +VOLUME_ID = os.environ.get("BOXLITE_VOLUME_ID", "") + +api_key = os.environ.get("BOXLITE_API_KEY") +if not api_key: + raise SystemExit("Set BOXLITE_API_KEY to your blk_live_... key before running this.") + + +async def main() -> None: + rt = Boxlite.rest( + BoxliteRestOptions( + url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), + credential=ApiKeyCredential(api_key), + ) + ) + + # This script creates no box and no volume, so removal is the only teardown + # it performs — and removal is what it is here to demonstrate. + try: + # remove() addresses the volume by id, not by name. + await rt.volumes.remove(VOLUME_ID) + print(f"Deletion accepted for {VOLUME_ID}") + + # Bounded poll: up to 60s for the volume to leave the listing. + for _ in range(12): + volumes = await rt.volumes.list() + if all(volume.id != VOLUME_ID for volume in volumes): + print("volume reclaimed") + return + await asyncio.sleep(5) + + print("volume still listed after 60s — reclamation runs on the platform's cycle") + except Exception as exc: + print(f"volume deletion failed: {exc!r}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +```typescript Node.js +// cloudVolumeDelete.ts — remove a volume, then wait for it to leave the listing +// Run: node cloudVolumeDelete.ts +import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; + +const VOLUME_ID = process.env.BOXLITE_VOLUME_ID ?? ""; + +const apiKey = process.env.BOXLITE_API_KEY; +if (!apiKey) { + throw new Error("Set BOXLITE_API_KEY to your blk_live_... key before running this."); +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function main(): Promise { + const rt = JsBoxlite.rest( + new BoxliteRestOptions({ + url: process.env.BOXLITE_REST_URL ?? "https://app.boxlite.ai/api", + credential: new ApiKeyCredential(apiKey), + }), + ); + + try { + // remove() addresses the volume by id, not by name. + await rt.volumes.remove(VOLUME_ID); + console.log(`Deletion accepted for ${VOLUME_ID}`); + + // Bounded poll: up to 60s for the volume to leave the listing. + for (let attempt = 0; attempt < 12; attempt++) { + const volumes = await rt.volumes.list(); + if (volumes.every((volume) => volume.id !== VOLUME_ID)) { + console.log("volume reclaimed"); + return; + } + await sleep(5000); + } + + console.log("volume still listed after 60s — reclamation runs on the platform's cycle"); + } catch (err) { + console.error(`volume deletion failed: ${err instanceof Error ? err.message : err}`); + } finally { + rt.close(); + } +} + +main(); +``` + +```bash REST +# Remove a volume, then watch its state until it leaves the listing. +BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://app.boxlite.ai/api}" +VOLUME_ID="${BOXLITE_VOLUME_ID:-}" + +curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/volumes/${VOLUME_ID}" \ + -H "Authorization: Bearer ${BOXLITE_API_KEY}" +echo "Deletion accepted for ${VOLUME_ID}" + +# Over REST you can watch the state directly, which the SDK does not expose. +for _ in $(seq 12); do + STATE=$(curl -fsS "${BOXLITE_REST_URL}/v1/volumes/${VOLUME_ID}" \ + -H "Authorization: Bearer ${BOXLITE_API_KEY}" | jq -r .state 2>/dev/null) + if [ -z "${STATE}" ] || [ "${STATE}" = "deleted" ]; then + echo "volume reclaimed" + exit 0 + fi + echo "state: ${STATE}" + sleep 5 +done +echo "volume still present after 60s — reclamation runs on the platform's cycle" +``` + + + +Deleting a volume that a running box still has mounted does not corrupt that box. The box stays usable. +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| You cannot tell whether a volume is ready | `state` is not on `VolumeInfo` | Read `state` over REST — see [What the SDK does not tell you](#what-the-sdk-does-not-tell-you) | +| `size_bytes` is always empty | The service does not report volume size on any response | Measure usage from inside a box, for example `du -sh /data` | +| A volume you deleted is still returned | Deletion is asynchronous — a REST read returns `state` `pending_delete` and the listing can lag | Poll with a bounded timeout instead of waiting for a `404` | +| A volume sits in `error` | The backend could not provision it | Read `error_reason` on `GET /v1/volumes/{id}`, then remove the volume and create a replacement | +| `401` on `/v1/volumes` | Missing, malformed, or expired API key | Send `Authorization: Bearer ` with a key from the console — see [API keys](/cloud/api-keys) | + +## Next steps + + + + Creating, naming, and mounting a volume, plus what survives the box. + + + The lifecycle switches that decide when a box — and its unsaved data — goes away. + + diff --git a/cloud/volumes.mdx b/cloud/volumes.mdx index a80251e..58c65db 100644 --- a/cloud/volumes.mdx +++ b/cloud/volumes.mdx @@ -1,43 +1,25 @@ --- title: "Persistent volumes on BoxLite Cloud" sidebarTitle: "Volumes" -description: "Create a managed volume from the SDK or the console, mount it into a box by id, and keep the data after the box is gone." +description: "Create a managed volume, give it a name you choose, mount it by name or by id, and keep the data after the box is gone." --- A box loses everything on its disk when it is destroyed. A volume does not — mount one into a box and the data outlives it. Use a volume for a dataset or model weights you do not want to fetch again, or for an agent's working state that has to survive the box that produced it. -## When you need a volume +## Prerequisites -- **An agent whose state must outlive its box.** A box on Cloud can stop when it goes idle and be deleted after stopping. Anything the agent wrote to the box's own disk goes away with it; anything it wrote through a volume mount is still there for the next box. -- **A large dataset or model you do not want to re-download.** Fetch it once into a volume, then mount that volume into every box that needs it instead of paying the download on each box. -- **Handing results from one box to the next.** One box produces artifacts under the mount path, a later box mounts the same volume and picks them up. +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- `pip install boxlite` for Python or `npm install @boxlite-ai/boxlite` for Node, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). +- A REST runtime. Managed volumes need one — a local runtime has no volume backend to resolve a volume reference against. -## Manage volumes from the SDK +## Quick example -The runtime you build with `Boxlite.rest(...)` carries a volumes API. `volumes` is a **property**, so write `rt.volumes` — no parentheses — and await the four methods hanging off it. - -| Call | Awaited | Parameters | Returns | -|---|---|---|---| -| `rt.volumes` | No — a property on the runtime | — | The volumes handle the four methods below live on | -| `rt.volumes.create()` | Yes | None. The call takes no arguments | `VolumeInfo` for the new volume | -| `rt.volumes.list()` | Yes | None | `list[VolumeInfo]` | -| `rt.volumes.get(id)` | Yes | `id`: `str`, required | `VolumeInfo`. Raises when no volume has that id | -| `rt.volumes.remove(id, force=False)` | Yes | `id`: `str`, required. `force`: `bool`, optional, default `False` | `None` | +Create a volume, mount it into a box at `/data`, write a file through the mount, and read it back. This runs as written once the two environment variables are set. -Every method returns `VolumeInfo` objects, whose fields are read-only: + -| Field | Type | Description | -|---|---|---| -| `id` | `str` | The server-assigned volume id. This is what you mount by, and what you pass to `get()` and `remove()` | -| `created_at` | `str` | Creation timestamp as an RFC 3339 string | -| `size_bytes` | `int` or `None` | The volume's size in bytes when the backend reports it | - -When the backend behind your runtime does not support named volumes, these four calls raise a BoxLite error. Handle it where you call them, as the example below does. - -This script creates a volume, mounts it at `/data`, writes a file through the mount, reads it back, and cleans up both the box and the volume in `finally`: - -```python -# cloud_volume.py — create a managed volume, mount it, write and read through it +```python Python +# cloud_volume.py — create a named volume, mount it, write and read through it # Run: python cloud_volume.py import asyncio import os @@ -68,16 +50,16 @@ async def main() -> None: volume = None box = None try: - # rt.volumes is a property, and create() takes no arguments. - # The id you mount by comes back on the returned VolumeInfo. - volume = await rt.volumes.create() - print(f"Created volume {volume.id} at {volume.created_at}") + # rt.volumes is a property. create() takes an optional name that you can + # mount by later, instead of carrying the id around. + volume = await rt.volumes.create(f"demo-{int(time.time())}") + print(f"Created volume {volume.name} (id {volume.id})") box = await rt.create( BoxOptions( image=IMAGE, - # (managed volume id, mount path inside the box) - volumes=[(volume.id, "/data")], + # (managed volume name or id, mount path inside the box) + volumes=[(volume.name, "/data")], ), name=f"volume-demo-{int(time.time())}", ) @@ -102,8 +84,8 @@ async def main() -> None: print(f"Exit code: {read_result.exit_code}") print(content) except Exception as exc: - # Auth failures, creation failures, and backends without named volume - # support all surface here. + # Auth failures, creation failures, and local runtimes without a volume + # backend all surface here. print(f"volume run failed: {exc!r}") finally: # Teardown in finally, so a failure above cannot leave a box billing. @@ -117,54 +99,213 @@ if __name__ == "__main__": asyncio.run(main()) ``` -To work with a volume you already have instead of a fresh one, pass its id straight to `BoxOptions(volumes=...)`, or call `await rt.volumes.get("")` first to confirm it exists. +```typescript Node.js +// cloudVolume.ts — create a named volume, mount it, write and read through it +// Run: node cloudVolume.ts +import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; + +const IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"; + +const apiKey = process.env.BOXLITE_API_KEY; +if (!apiKey) { + throw new Error("Set BOXLITE_API_KEY to your blk_live_... key before running this."); +} + +async function main(): Promise { + const rt = JsBoxlite.rest( + new BoxliteRestOptions({ + url: process.env.BOXLITE_REST_URL ?? "https://app.boxlite.ai/api", + credential: new ApiKeyCredential(apiKey), + }), + ); + + const stamp = Math.floor(Date.now() / 1000); + let volume = null; + let box = null; + try { + // rt.volumes is a property. create() takes an optional name you can mount by. + volume = await rt.volumes.create(`demo-${stamp}`); + console.log(`Created volume ${volume.name} (id ${volume.id})`); -## Create a volume in the console + box = await rt.create( + { + image: IMAGE, + // [managed volume name or id, mount path inside the box] + volumes: [[volume.name, "/data"]], + }, + `volume-demo-${stamp}`, + ); + await box.start(); + + // Write through the mount, not to the box's own disk. + const write = await (await box.exec("sh", ["-c", "echo 'subtitle model v3' > /data/notes.txt"])).wait(); + if (write.exitCode !== 0) { + console.error(`write failed with exit code ${write.exitCode}`); + return; + } + + const read = await box.exec("cat", ["/data/notes.txt"]); + const stdout = await read.stdout(); + let content = ""; + while (true) { + const line = await stdout.next(); + if (line === null) break; + content += line; + } + const result = await read.wait(); + + console.log(`Exit code: ${result.exitCode}`); + console.log(content); + } catch (err) { + // Auth failures, creation failures, and local runtimes without a volume + // backend all surface here. + console.error(`volume run failed: ${err instanceof Error ? err.message : err}`); + } finally { + // Teardown in finally, so a failure above cannot leave a box billing. + if (box !== null) await rt.remove(box.id, true); + if (volume !== null) await rt.volumes.remove(volume.id); + rt.close(); + } +} + +main(); +``` -The console is the other way to create a volume, and the one to use when you want to see what you own. +```bash REST +# Requires BOXLITE_API_KEY. Create a key in the console: /cloud/api-keys +BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://app.boxlite.ai/api}" +IMAGE="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" +NAME="demo-$(date +%s)" -1. Open **Volumes** in the console and click **New Volume**. -2. Fill in **Name** — the only field. Pick something you will recognize later, such as `subtitle-models`. -3. Create it, then give it a few seconds to become ready before you mount it. +# 1. Create a named volume. An empty body creates an unnamed one, whose name +# the server sets to the id. +VOLUME=$(curl -fsS -X POST "${BOXLITE_REST_URL}/v1/volumes" \ + -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"${NAME}\"}") +echo "${VOLUME}" | jq '{id, name, state}' + +# 2. Mount it by name. The wire field is managed_volume — it takes a name or an id. +curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes" \ + -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d "{\"image\":\"${IMAGE}\",\"volumes\":[{\"managed_volume\":\"${NAME}\",\"guest_path\":\"/data\"}]}" \ + | jq '{id, name}' + +# 3. Clean up when you are done. +curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/volumes/$(echo "${VOLUME}" | jq -r .id)" \ + -H "Authorization: Bearer ${BOXLITE_API_KEY}" +``` + + -The volume now exists independently of any box. You can mount it into a box, destroy that box, and mount it into a different one later. `rt.volumes.list()` and the **Volumes** page report the same set of volumes. +Two things in that script are worth pausing on, and the rest of this page builds on them: the volume carries a **name you chose**, and the box mounts it by that name. -### Names live in the console, ids come from the SDK +## When you need a volume -The **New Volume** dialog takes a **Name**. `create()` accepts no arguments, and the `id` on the returned `VolumeInfo` is assigned by the server. So a volume you create from code carries no name you chose, and the id is the handle for everything that follows — mounting, `get()`, and `remove()`. +- **An agent whose state must outlive its box.** A box on Cloud can stop when it goes idle and be deleted after stopping. Anything the agent wrote to the box's own disk goes away with it; anything it wrote through a volume mount is still there for the next box. +- **A large dataset or model you do not want to re-download.** Fetch it once into a volume, then mount that volume into every box that needs it instead of paying the download on each box. +- **Handing results from one box to the next.** One box produces artifacts under the mount path, a later box mounts the same volume and picks them up. +- **A fleet that addresses storage by name.** Workers mount `("training-data", "/data")` without any of them having to look up an id first. -Create in the console when a human will need to recognize the volume in a list. Create from code when your program keeps the id. +## Name a volume and mount it by that name -## Manage volumes over REST +A volume has both a server-assigned `id` and a `name`, and **either one mounts it**. When you create a volume without a name, the server uses the id as the name. -Reach for REST when you are working outside the Python SDK. Volumes live under the `/v1/volumes` route, authenticated with `Authorization: Bearer ` like every other route. Create a key on the **API Keys** page first — see [API keys](/cloud/api-keys). +Choosing your own name is what lets a worker mount the volume it wants without knowing the id. The two halves can live in different processes that never exchange an id: -```bash -# Requires BOXLITE_API_KEY. Create a key in the console: /cloud/api-keys -BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://app.boxlite.ai/api}" + + +```python Python +# In the process that provisions storage: +volume = await rt.volumes.create("training-data") -curl -fsS "${BOXLITE_REST_URL}/v1/volumes" \ +# In a worker that only knows the name: +box = await rt.create(BoxOptions(image=IMAGE, volumes=[("training-data", "/data")])) +``` + +```typescript Node.js +// In the process that provisions storage: +const volume = await rt.volumes.create("training-data"); + +// In a worker that only knows the name: +const box = await rt.create({ image: IMAGE, volumes: [["training-data", "/data"]] }); +``` + +```bash REST +# The wire field is managed_volume, and it accepts a name or an id. +curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes" \ -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ - | jq . + -H 'Content-Type: application/json' \ + -d '{"image":"'"${IMAGE}"'","volumes":[{"managed_volume":"training-data","guest_path":"/data"}]}' ``` -Deletion is `DELETE /v1/volumes/{volume_id}`, which returns `204`. Read [Deletion is asynchronous](#deletion-is-asynchronous) before you build on top of it. + + +Names are unique within your organization, so a name is a stable address across processes and across time. An id is stable too, but you have to carry it somewhere. + +## Parameters and returns + +The runtime you build with `Boxlite.rest(...)` carries a volumes API. `volumes` is a **property**, so write `rt.volumes` — no parentheses — and await the four methods hanging off it. + +| Call | Parameters | Returns | +|---|---|---| +| `rt.volumes` | Not awaited — a property on the runtime | The volumes handle the four methods below live on | +| `rt.volumes.create(name=None)` | `name`: `str`, optional. Mountable in place of the id; the server names the volume after its id when omitted | `VolumeInfo` for the new volume | +| `rt.volumes.list()` | None | `list[VolumeInfo]` | +| `rt.volumes.get(id)` | `id`: `str`, required | `VolumeInfo`. Raises when no volume has that id | +| `rt.volumes.remove(id, force=False)` | `id`: `str`, required. `force`: `bool`, optional, default `False` | `None` | + +In Node the same four methods are `create(name?)`, `list()`, `get(id)`, and `remove(id, force?)`. + +`VolumeInfo` fields are read-only: + +| Field | Python | Node | Description | +|---|---|---|---| +| id | `id` | `id` | Server-assigned. Mounts the volume, and addresses `get()` and `remove()` | +| name | `name` | `name` | Yours if you passed one to `create()`, otherwise the id. Also mounts the volume | +| created at | `created_at` | `createdAt` | RFC 3339 string | +| size | `size_bytes` | `sizeBytes` | **Always empty on Cloud** — the service does not report volume size on either the list or the single-volume response. See [Volume operations](/cloud/volume-operations#what-the-sdk-does-not-tell-you) | + + +To work with a volume you already have, pass its name or id straight to the box's `volumes` field, or call `get()` first to confirm it exists. + +## Create a volume in the console + +The console is the other way to create a volume, and the one to use when you want to see what you own. - -Over REST, the operations documented here are listing and deletion. To create a volume, call `await rt.volumes.create()` or use the console. - +1. Open **Volumes** in the console and click **New Volume**. +2. Fill in **Name** — the only field. Pick something you will recognize later, such as `subtitle-models`. +3. Create it, then give it a few seconds to become ready before you mount it. + +The volume now exists independently of any box. You can mount it into a box, destroy that box, and mount it into a different one later. `rt.volumes.list()` and the **Volumes** page report the same set of volumes, and a name set in either place mounts the same way. ## Mount a volume into a box -Mounting is configured at creation time through the `volumes` field on `BoxOptions`. Each element is a `(volume, mount_path)` tuple. **The first element is the managed volume's id — the value `create()` returned, or the id the console shows — not a path on your machine.** That is the mental switch to make coming from open source. +Mounting is configured at creation time through the `volumes` field on the box options. Each element is a `(volume, mount_path)` pair. + +**The first element is a managed volume's name or id — not a path on your machine.** That is the mental switch to make coming from open source. -Two more shapes are specific to Cloud, both visible in the example above: `Boxlite.rest(...)` needs no `path_prefix`, and you reclaim the box with `await rt.remove(box.id, force=True)`. For the full `BoxOptions` parameter table and `exec` semantics, see the [Python SDK reference](/reference/python). For the host-directory mount forms and read-only mounts, see [Volumes and mounts](/manage-sandbox/volumes). +The mount path has to be an absolute path that is not the root and not a system directory. The service rejects the box otherwise: + +| Mount path | Result | +|---|---| +| `/data`, `/mnt/models`, `/srv/cache` | Accepted | +| `data` or any relative path | Rejected — must be absolute | +| `/` or `//` | Rejected — cannot mount to the root directory | +| `/data/../etc` | Rejected — cannot contain relative path components | +| `/data//cache` | Rejected — cannot contain consecutive slashes | +| `/proc` `/sys` `/dev` `/boot` `/etc` `/bin` `/sbin` `/lib` `/lib64`, or anything under them | Rejected — cannot mount to a system directory | + +For the full box options table and `exec` semantics, see the [Python SDK reference](/reference/python) or the [Node.js SDK reference](/reference/nodejs). For host-directory mount forms, see [Volumes and mounts](/manage-sandbox/volumes). ## Data outlives the box -The property that makes a volume worth using: write through the mount in one box, destroy that box, mount the same volume in a different box, and the data reads back. This is verified behaviour on Cloud — the volume is backed by managed storage, not by the box. +The property that makes a volume worth using: write through the mount in one box, destroy that box, mount the same volume in a different box, and the data reads back. The volume is backed by managed storage, not by the box. + + -```python +```python Python import asyncio import os import time @@ -176,14 +317,15 @@ from boxlite import ( BoxOptions, ) -VOLUME_ID = os.environ.get("BOXLITE_VOLUME_ID", "") +# A name is easier to carry between processes than an id. +VOLUME = os.environ.get("BOXLITE_VOLUME", "") IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" async def run_in_fresh_box(rt, name, script): """Create a box with the volume mounted, run one shell script, then remove the box.""" box = await rt.create( - BoxOptions(image=IMAGE, volumes=[(VOLUME_ID, "/data")]), + BoxOptions(image=IMAGE, volumes=[(VOLUME, "/data")]), name=name, ) try: @@ -232,99 +374,154 @@ async def main(): asyncio.run(main()) ``` -Only what you write **under the mount path** survives. A file written to the box's own filesystem outside `/data` goes away with the box. - -## What is different from open source - - -**Host bind mounts are ignored over REST.** In open source, `volumes=[("/home/you/data", "/data")]` mounts a directory from your machine. On Cloud, a host path in that first position is silently ignored — the box starts, the mount does not happen, and nothing you write is persisted. There is no error to catch. If you are porting code, replace every host path with a managed volume identifier. - - -| | Open source | Cloud | -|---|---|---| -| What you mount | A directory on the host machine | A managed volume, identified by its id | -| Where the data lives | Your filesystem | Managed storage in the BoxLite resource pool | -| Creating storage | Make a directory | `await rt.volumes.create()`, or the console's **New Volume** dialog | -| Host bind mounts | Supported | Ignored | - -For the host-directory mount options, read-only mounts, and `copy_in` / `copy_out`, see [Volumes and mounts](/manage-sandbox/volumes). For the complete side-by-side, see [Cloud vs open source](/cloud/vs-opensource). - -## Deletion is asynchronous +```typescript Node.js +import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; + +// A name is easier to carry between processes than an id. +const VOLUME = process.env.BOXLITE_VOLUME ?? ""; +const IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"; + +// Create a box with the volume mounted, run one shell script, then remove the box. +async function runInFreshBox(rt, name: string, script: string): Promise<[number, string]> { + const box = await rt.create({ image: IMAGE, volumes: [[VOLUME, "/data"]] }, name); + try { + await box.start(); + const execution = await box.exec("sh", ["-c", script]); + const stdout = await execution.stdout(); + let output = ""; + while (true) { + const line = await stdout.next(); + if (line === null) break; + output += line; + } + const result = await execution.wait(); + return [result.exitCode, output]; + } finally { + // The box is gone after this line; the volume is not. + await rt.remove(box.id, true); + } +} + +async function main(): Promise { + const rt = JsBoxlite.rest( + new BoxliteRestOptions({ + url: process.env.BOXLITE_REST_URL ?? "https://app.boxlite.ai/api", + credential: new ApiKeyCredential(process.env.BOXLITE_API_KEY!), + }), + ); + const stamp = Math.floor(Date.now() / 1000); + + try { + // Box A writes, then is destroyed. + const [writeCode] = await runInFreshBox( + rt, + `volume-writer-${stamp}`, + "echo 'produced by box A' > /data/handoff.txt", + ); + if (writeCode !== 0) { + console.error(`box A write failed with exit code ${writeCode}`); + return; + } + + // Box B is a different box on the same volume. + const [readCode, output] = await runInFreshBox(rt, `volume-reader-${stamp}`, "cat /data/handoff.txt"); + console.log(`box B exit code: ${readCode}`); + console.log(`box B read back: ${output}`); + } catch (err) { + console.error(`handoff failed: ${err instanceof Error ? err.message : err}`); + } finally { + rt.close(); + } +} + +main(); +``` -`await rt.volumes.remove(volume_id)` returns `None`, and `DELETE /v1/volumes/{volume_id}` returns `204`. Either way that is an acknowledgement, not a completed deletion. Immediately afterwards: +```bash REST +# Two boxes, one volume: the first writes, the second reads after the first is gone. +BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://app.boxlite.ai/api}" +VOLUME="${BOXLITE_VOLUME:-}" +IMAGE="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" +AUTH=(-H "Authorization: Bearer ${BOXLITE_API_KEY}" -H 'Content-Type: application/json') + +make_box() { + curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes" "${AUTH[@]}" \ + -d "{\"image\":\"${IMAGE}\",\"volumes\":[{\"managed_volume\":\"${VOLUME}\",\"guest_path\":\"/data\"}]}" \ + | jq -r .id +} + +# Box A writes, then is destroyed. +A=$(make_box) +curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes/${A}/exec" "${AUTH[@]}" \ + -d '{"command":"sh","args":["-c","echo '"'"'produced by box A'"'"' > /data/handoff.txt"]}' > /dev/null +curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/boxes/${A}?force=true" "${AUTH[@]}" + +# Box B is a different box on the same volume. +B=$(make_box) +curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes/${B}/exec" "${AUTH[@]}" \ + -d '{"command":"cat","args":["/data/handoff.txt"]}' +curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/boxes/${B}?force=true" "${AUTH[@]}" +``` -- A REST read of that volume returns `200` with a `state` of `pending_delete` — **not** a `404`. -- A listing can still include the volume for a short window. -- Reclamation finishes on the platform's own cycle. + -So do not write code that waits for a `404`. Poll the listing with a bounded timeout and treat a volume that has left the listing as done: +Only what you write **under the mount path** survives. A file written to the box's own filesystem outside `/data` goes away with the box. -```python -# cloud_volume_delete.py — remove a volume, then wait for it to leave the listing -# Run: python cloud_volume_delete.py -import asyncio -import os +## Read-only mounts -from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions +Read-only managed volumes are not supported. The SDK refuses the mount before the request leaves your process, rather than mounting it writable and letting you believe it is protected: -VOLUME_ID = os.environ.get("BOXLITE_VOLUME_ID", "") +```text +read-only managed volumes are not supported yet; mount "training-data" read-write +``` -api_key = os.environ.get("BOXLITE_API_KEY") -if not api_key: - raise SystemExit("Set BOXLITE_API_KEY to your blk_live_... key before running this.") +Mount read-write and enforce read-only behaviour in your own code, or use a separate volume for data no box should modify. +## What is different from open source -async def main() -> None: - rt = Boxlite.rest( - BoxliteRestOptions( - url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), - credential=ApiKeyCredential(api_key), - ) - ) + +**Host bind mounts are rejected over REST.** In open source, `volumes=[("/home/you/data", "/data")]` mounts a directory from your machine. On Cloud that first element must be a managed volume's name or id. The SDK refuses the box **before any network request goes out**: - # This script creates no box and no volume, so removal is the only teardown - # it performs — and removal is what it is here to demonstrate. - try: - await rt.volumes.remove(VOLUME_ID) - print(f"Deletion accepted for {VOLUME_ID}") - - # Bounded poll: up to 60s for the volume to leave the listing. - for _ in range(12): - volumes = await rt.volumes.list() - if all(volume.id != VOLUME_ID for volume in volumes): - print("volume reclaimed") - return - await asyncio.sleep(5) - - print("volume still listed after 60s — reclamation runs on the platform's cycle") - except Exception as exc: - print(f"volume deletion failed: {exc!r}") +```text +host bind mounts are only supported by the local runtime; mount a managed volume by id or name instead +``` +So the mistake surfaces immediately, rather than as a box that starts with an empty mount. If you are porting code, replace every host path with a managed volume reference. + -if __name__ == "__main__": - asyncio.run(main()) -``` +| | Open source | Cloud | +|---|---|---| +| What you mount | A directory on the host machine | A managed volume, by name or id | +| Where the data lives | Your filesystem | Managed storage in the BoxLite resource pool | +| Creating storage | Make a directory | `create(name)`, or the console's **New Volume** dialog | +| Host bind mounts | Supported | Rejected | +| Read-only mounts | Supported | Not supported | -Deleting a volume that a running box still has mounted does not corrupt that box. The box stays usable. +For host-directory mount options, read-only mounts, and `copy_in` / `copy_out`, see [Volumes and mounts](/manage-sandbox/volumes). For the complete side-by-side, see [Cloud vs open source](/cloud/vs-opensource). ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| -| The mount point is empty and nothing is persisted, with no error | A host path was passed as the first tuple element. Host bind mounts are ignored over REST | Pass the managed volume id: `volumes=[("", "/data")]` | -| `volumes tuples must be (host, guest[, read_only])` | A `volumes` entry is a tuple of the wrong length | Give each mount exactly the volume id and the mount path: `volumes=[(volume.id, "/data")]` | -| `volumes entries must be tuple or dict` | A `volumes` entry is neither — most often a bare id string | Wrap each mount in a tuple: `volumes=[(volume.id, "/data")]`, not `volumes=[volume.id]` | -| `rt.volumes(...)` fails when you call it | `volumes` is a property on the runtime, not a method | Drop the parentheses after `volumes`: `await rt.volumes.create()` | -| A BoxLite error from `create()`, `list()`, `get()`, or `remove()` | The backend behind that runtime does not support named volumes | Catch the error where you call it. These four methods require a backend with named volume support | -| `get()` raises for an id you expect to exist | No volume has that id — a typo, or it was already removed | Call `await rt.volumes.list()` and read the id off the `VolumeInfo` you want | -| A volume you just created does not work | A new volume takes a few seconds to become ready | Wait until the volume is listed, then create the box that mounts it | -| A volume you deleted is still returned | Deletion is asynchronous — a REST read returns `state` `pending_delete` and the listing can lag | Poll the listing with a bounded timeout instead of waiting for a `404` | -| Files are gone after the box is removed | The data was written outside the mount path, so it lived on the box's own disk | Write under the mount path, for example `/data/results.json`, and read it back from a box that mounts the same volume | +| The box is refused with a host-path error | A path from your machine was passed as the first element. Cloud takes a managed volume reference there | Pass the volume's name or id: `volumes=[("training-data", "/data")]` | +| `read-only managed volumes are not supported yet` | A mount asked for read-only, which Cloud does not support | Mount read-write; the SDK refuses rather than silently mounting writable | +| `volumes tuples must be (host, guest[, read_only])` | A `volumes` entry is a tuple of the wrong length | Give each mount exactly the volume reference and the mount path | +| `volumes entries must be tuple or dict` | A `volumes` entry is neither — most often a bare string | Wrap each mount in a tuple: `volumes=[(volume.name, "/data")]`, not `volumes=[volume.name]` | +| `rt.volumes(...)` fails when you call it | `volumes` is a property on the runtime, not a method | Drop the parentheses: `await rt.volumes.create()` | +| A BoxLite error from `create()`, `list()`, `get()`, or `remove()` | The runtime has no volume backend — a local runtime cannot resolve a managed volume reference | Build the runtime with `Boxlite.rest(...)` against Cloud | +| `Invalid mount path ... (cannot mount to system directory)` | The mount path is `/proc`, `/etc`, `/bin`, or another system directory | Mount somewhere like `/data` or `/mnt/models` | +| `Invalid mount path ... (must be absolute)` | The mount path is relative | Start the path with `/` | +| `get()` raises for an id you expect to exist | `get()` takes the id, not the name — or the volume was already removed | Call `list()` and read the `id` off the `VolumeInfo` you want | +| Files are gone after the box is removed | The data was written outside the mount path, so it lived on the box's own disk | Write under the mount path, for example `/data/results.json` | +| A volume is not ready, is stuck, or will not go away after deletion | Volume state and deletion are covered on their own page | See [Volume operations](/cloud/volume-operations) | | `401` on `/v1/volumes` | Missing, malformed, or expired API key | Send `Authorization: Bearer ` with a key from the console — see [API keys](/cloud/api-keys) | ## Next steps + + Reading volume state over REST, and deleting a volume when reclamation is asynchronous. + Images, sizes, and the console lifecycle switches that make a volume necessary. diff --git a/docs.json b/docs.json index 2a325cd..4d9bef3 100644 --- a/docs.json +++ b/docs.json @@ -43,7 +43,7 @@ "navigation": { "tabs": [ { - "tab": "BoxLite Opensource", + "tab": "BoxLite Open Source", "groups": [ { "group": "Getting started", @@ -195,7 +195,9 @@ "group": "Volumes", "icon": "database", "root": "cloud/volumes", - "pages": [] + "pages": [ + "cloud/volume-operations" + ] }, { "group": "Network", diff --git a/llms.txt b/llms.txt index d63c9c7..f244245 100644 --- a/llms.txt +++ b/llms.txt @@ -2,7 +2,7 @@ > BoxLite is an embeddable microVM sandbox for AI agents — stateful, sub-second boot, hardware-level isolation, no daemon required. -## BoxLite Opensource +## BoxLite Open Source ### Getting started - [BoxLite documentation](/index.mdx): Run arbitrary code, commands, browsers, desktops, or entire AI agents inside hardware-isolated microVM sandboxes that start in about a secon… @@ -101,7 +101,8 @@ - [Configure a box on BoxLite Cloud](/cloud/boxes.mdx): Pick an image and a size for a Cloud box, and understand the three lifecycle controls the platform applies while it runs. ### Volumes -- [Persistent volumes on BoxLite Cloud](/cloud/volumes.mdx): Create a managed volume from the SDK or the console, mount it into a box by id, and keep the data after the box is gone. +- [Persistent volumes on BoxLite Cloud](/cloud/volumes.mdx): Create a managed volume, give it a name you choose, mount it by name or by id, and keep the data after the box is gone. +- [Operating volumes on BoxLite Cloud](/cloud/volume-operations.mdx): Read the volume state the SDK does not expose, and delete a volume knowing that reclamation is asynchronous. ### Network - [Reach a service running inside a box](/cloud/network.mdx): Open a tunnel to a port inside a Cloud box to reach an HTTP server, a WebSocket endpoint, or any TCP service — and understand the box's outb… From ac62b654623bbc23db3f163de297969bffce2790 Mon Sep 17 00:00:00 2001 From: Mandalorian-Wang <275727085+Mandalorian-Wang@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:26:49 +0900 Subject: [PATCH 3/3] docs(cloud): restructure the Cloud tab and add verified pricing pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every nav group root is now a summary plus an index of its children, and the substantive content moved into task-shaped sibling pages. Cloud goes from 12 pages to 24, still two levels deep. root before after cloud/index 115 45 cloud/boxes 299 41 cloud/volumes 531 39 cloud/network 461 44 cloud/pricing 281 45 The splits were scoped by measuring section sizes, not page length. One section was 89% of cloud/network; two were 67% of cloud/volumes. cloud/boxes had no dominant section, so it was split by topic rather than to relieve bloat. New pricing pages, with rates read from Commerce's anonymous /usage-prices rather than from the repo's test fixtures: - cloud/pricing rates and section index - cloud/box-costs formula, size costs, and which hours are counted - cloud/cost-controls auto_stop/auto_delete, in Python, Node, and Go - cloud/plans plan tiers and the $20/$161/$601 break-even points - cloud/billing rewritten as account operations; adds automatic reload Correctness fix carried in this change: cloud/boxes claimed a box created over REST gets auto_stop=0 and "keeps running until you stop it". It does not. The SDK omits the field when unset (serde skip_serializing_if), and the platform resolves it to DEFAULT_AUTO_STOP_SECONDS = 900 (box.service.ts resolveLifecycle Policy). The published page contradicted itself, warning elsewhere that a job is stopped at 15 minutes. Corrected in three places, found by grepping for the class rather than the instance. cloud/volume-operations became an actual CRUD page. rt.volumes.get() had no runnable code anywhere, list() only appeared incidentally inside a deletion demo, and the headings named gotchas rather than operations. Also documents that there is no update operation — create/list/get/remove is the whole surface. Removed a dev-environment hostname that had been used to show the rate list was checkable, and deleted cloud/index's runnable script, which duplicated cloud/quickstart and cloud/vs-opensource. Verified: mint broken-links clean, lint-docs 180 pages with no soft promises, 101 pages with every internal link and anchor resolving, llms.txt regenerated and --check clean, all 24 Cloud routes rendering 200 locally, and the Go example type-checking against the SDK with go vet. Not verified: the Starter/Pro/Max price, quota, and concurrency figures on cloud/plans. Commerce's /plan catalog requires authentication, so those three rows carry over from the previously published page and the break-even table is derived from them. Co-Authored-By: Claude Opus 5 --- cloud/billing.mdx | 124 ++++---- cloud/box-costs.mdx | 98 +++++++ cloud/box-from-code.mdx | 115 ++++++++ cloud/box-images.mdx | 85 ++++++ cloud/box-lifecycle.mdx | 115 ++++++++ cloud/box-sizes.mdx | 62 ++++ cloud/boxes.mdx | 325 +++------------------ cloud/cost-controls.mdx | 190 +++++++++++++ cloud/data-across-boxes.mdx | 185 ++++++++++++ cloud/index.mdx | 106 ++----- cloud/mount-a-volume.mdx | 220 +++++++++++++++ cloud/network-policy.mdx | 2 +- cloud/network.mdx | 479 ++----------------------------- cloud/plans.mdx | 86 ++++++ cloud/port-forwarding.mdx | 117 ++++++++ cloud/pricing.mdx | 45 +++ cloud/quickstart.mdx | 4 +- cloud/raw-streams.mdx | 122 ++++++++ cloud/serve-http.mdx | 139 +++++++++ cloud/tunnels.mdx | 147 ++++++++++ cloud/volume-operations.mdx | 225 +++++++++------ cloud/volume-reference.mdx | 158 +++++++++++ cloud/volumes.mdx | 544 ++---------------------------------- cloud/vs-opensource.mdx | 6 +- docs.json | 27 +- llms.txt | 27 +- 26 files changed, 2235 insertions(+), 1518 deletions(-) create mode 100644 cloud/box-costs.mdx create mode 100644 cloud/box-from-code.mdx create mode 100644 cloud/box-images.mdx create mode 100644 cloud/box-lifecycle.mdx create mode 100644 cloud/box-sizes.mdx create mode 100644 cloud/cost-controls.mdx create mode 100644 cloud/data-across-boxes.mdx create mode 100644 cloud/mount-a-volume.mdx create mode 100644 cloud/plans.mdx create mode 100644 cloud/port-forwarding.mdx create mode 100644 cloud/pricing.mdx create mode 100644 cloud/raw-streams.mdx create mode 100644 cloud/serve-http.mdx create mode 100644 cloud/tunnels.mdx create mode 100644 cloud/volume-reference.mdx diff --git a/cloud/billing.mdx b/cloud/billing.mdx index b858c47..b9fb451 100644 --- a/cloud/billing.mdx +++ b/cloud/billing.mdx @@ -1,107 +1,85 @@ --- -title: "Plans, wallet, and usage" -sidebarTitle: "Billing" -description: "How BoxLite Cloud charges for boxes: a plan gives you included quota and a concurrency limit each cycle, and your prepaid wallet funds everything beyond it." +title: "Managing billing" +sidebarTitle: "Managing billing" +description: "Fund your wallet, set automatic reload so boxes never stop for lack of balance, read what you have actually been charged, and know the per-box ceilings that apply to every box in your organization." --- -A plan gives you two things each billing cycle: an included quota of usage and a concurrency limit on how many boxes run at once. The wallet is a prepaid balance that funds usage once that quota is consumed. Everything on this page lives under **Billing** in the console, across its **Overview**, **Usage**, and **Wallet** tabs. +This page covers running the account. For what a box costs see [What a box costs](/cloud/box-costs); for whether to subscribe see [Wallet or a plan?](/cloud/plans). -## How usage is funded +Your **wallet** is a prepaid balance. It funds usage once a plan's included quota is consumed, and it funds everything if you have no plan. -The console states the rule on its own usage chart: **quota covers first, the wallet funds the rest**. Two separate ideas sit behind that sentence. +## The wallet -**Included quota** comes with a plan. It is an amount of usage — expressed in dollars — that your plan covers during the current billing cycle. You do not top it up and you do not manage it; it arrives with the plan and is tied to the cycle. +The **Wallet** tab shows your available balance and what you have spent this month. -**Wallet balance** is prepaid and entirely yours to manage. You add funds to it, and it pays for usage after the cycle's included quota is consumed. An account can hold a wallet balance with or without a plan. + +The figure shown as **available** is your *ongoing* balance: it already reflects usage accrued this cycle but not yet settled. That is why it drifts downward while boxes run, with no payment having been taken. + -So a box you run draws on your quota until the quota for that cycle is gone, and from that point on it draws on your wallet. - -The **New box** dialog reports a box's **PRICE PER HOUR** before you create it, so you can see what a size costs while you are choosing it. - -## Plans - -The **Overview** tab lists the available plans and marks your **ACTIVE PLAN**. - -| Plan | Price | Included quota | Concurrency limit | -|---|---|---|---| -| Starter | $19/mo | $30 | 20 boxes | -| Pro | $149/mo | $250 | 100 boxes | -| Max | $499/mo | $900 | 1000 boxes | - -**Enterprise** is handled directly: custom limits and compliance review. Contact sales at [sales@boxlite.ai](mailto:sales@boxlite.ai). - -### What the concurrency limit means for you - -The concurrency limit caps how many boxes run at the same time — not how many you create over a month, and not how much work each one does. If you are building an agent fleet, this is the number to size against your workload: an orchestrator that fans out to 40 parallel agents, each in its own box, needs a plan whose concurrency limit clears 40. A single long-lived box that runs all day occupies one slot the whole time. - -Quota and concurrency are independent. You can hit the concurrency limit with quota to spare, and you can exhaust quota while running a single box. - -### Accounts with no plan - -An account does not have to carry a plan. The console shows **ACTIVE PLAN** as `No plan` in that case, and usage draws on the wallet balance instead of on an included quota. Pick a plan from **ALL PLANS** on the **Overview** tab when you want a cycle quota and a plan concurrency limit. - -## Per-box resource ceilings - -**Billing** also publishes the resource ceilings that apply to every box in your organization, under **BOX LIMITS** — "Resources limit per box": - -| Resource | Ceiling per box | -|---|---| -| Compute | 4 vCPU | -| Memory | 32 GiB | -| Storage | 120 GiB | +### Add funds -The console gives its reason plainly: limits mitigate misuse and keep box and compute capacity fairly available across all users. +Click **Top up** and pick a preset amount — **$25**, **$500**, **$1,000**, or **$2,000** — or enter a custom amount. The console redirects you to Stripe to complete the payment. -These are ceilings on a **single box**, which makes them a different constraint from your plan's concurrency limit. The ceilings bound how large one box can be; the concurrency limit bounds how many boxes run at once. See [Boxes](/cloud/boxes) for the preset sizes and the custom size fields you choose from within these ceilings. If your workload genuinely needs a larger single box, that is an Enterprise conversation — custom limits go through [sales@boxlite.ai](mailto:sales@boxlite.ai). +### Set automatic reload -## Wallet +Manual top-ups are why wallets hit zero at 3am. Automatic reload watches the balance and refills it: set a **threshold** to drop below and a **target** to be refilled to, and the console reads the result back as `Below $20.00 → $100.00`. -The **Wallet** tab shows **WALLET BALANCE** (the amount available) and **SPENT THIS MONTH**. The balance is prepaid: you put money in before you spend it, and it funds usage beyond your plan's included quota. +It prefills $20 and $100. Reload stays off until you save it, and it charges your connected card — so attach one first. -### Add funds +### Connect a payment method -Click **Top up** and choose one of the preset amounts — **$25**, **$500**, **$1,000**, or **$2,000** — or enter a custom amount. The console tells you what happens next: "You will be redirected to Stripe to complete the payment." +Until a card is attached the console shows the payment method as not connected, with a **Connect** button beside it. Connecting one earns an additional **$100 of credits**. ### Redeem a coupon -**REDEEM COUPON** takes a coupon code and credits your account: "Enter a coupon code to redeem your credits." - -### Connect a payment method - -Until you attach a card, **PAYMENT METHOD** reads `Payment method not connected`, with a **Connect** button beside it. The console attaches an offer to doing so, in its own words: "Connect a credit card to receive an additional $100 of credits." - -## Track usage +**Redeem coupon** takes a coupon code and credits the account. Credits are tracked separately from your topped-up balance, so the console shows credits remaining against credits granted. -The **Usage** tab answers the question "where is my money going" with **COST OVER TIME** — settled cost by day for the last 30 days, viewable as either a **CHART** or a **LIST**. +## Read the usage chart -The view splits cost into two series: +The **Usage** tab answers "where is my money going" with settled cost by day for the last 30 days, as either a chart or a list. It splits cost into two series: | Series | What it represents | |---|---| | **Quota-covered** | Cost your plan's included quota absorbed | | **From wallet** | Cost your prepaid wallet balance paid for | -Read it in that order, because it mirrors how funding works. While quota remains for the cycle, the days you see are quota-covered. Once the cycle's quota is consumed, **From wallet** is the series that grows — and that transition is the moment worth watching, because it is where a running box starts drawing on money you topped up. +The moment worth watching is when the second series starts growing: that is where this cycle's quota ran out and your boxes began drawing on money you topped up. -Check this tab **before** you scale a workload up. Look at what a typical day already costs and which series it lands in, then multiply by the fan-out you are about to add. A workload that doubles its box count doubles a quota-covered day just as readily as a wallet-funded one; the difference is only whether you notice. +This tab is the authority on what you were actually charged — the hourly figures in the **New Box** dialog and on [What a box costs](/cloud/box-costs) are estimates. **Check it before you scale a workload up:** see what a typical day already costs, then multiply by the fan-out you are about to add. -## Keep costs predictable +## Per-box resource ceilings + +**Billing** also publishes the ceilings that apply to every box in your organization: + +| Resource | Ceiling per box | +|---|---| +| Compute | 4 vCPU | +| Memory | 32 GiB | +| Storage | 120 GiB | -The console prices a box per hour, so the habits that keep spend flat are all about not leaving boxes running behind you. +These bound how large a **single box** can be — a different constraint from your plan's concurrency limit, which bounds how many boxes run at once. A request above a ceiling is rejected before the box is created, so you get a clear error rather than a box that fails later. See [Choose a size](/cloud/box-sizes) for the presets and custom fields available within them. -- **Remove boxes when the work is done.** A forgotten box is the most common source of surprise cost. Tear it down as part of your task, not as cleanup you plan to do later. See [Boxes](/cloud/boxes). -- **Treat stop-when-idle as a safety net, not a budget.** It is a genuine backstop for boxes you forgot, but the console is explicit about its blind spot: idle means no SDK, terminal, or preview traffic, and work running inside the box does not count. A busy box is never idle, so stop-when-idle will not cap what it spends. -- **Size the box to the job.** The largest size is not the safe default. Pick the smallest preset that runs your workload comfortably, and reach for a custom size only when a preset genuinely does not fit — see [Boxes](/cloud/boxes). -- **Size your fleet against the concurrency limit.** Know the limit on your plan before you fan out, so an orchestrator does not stall partway through a batch. -- **Look at the Usage tab before a large run.** One glance at the last 30 days tells you whether you are still on quota and what a day of the current workload costs. +If a workload genuinely needs a larger single box, that is an Enterprise conversation — custom limits go through [sales@boxlite.ai](mailto:sales@boxlite.ai). ## Troubleshooting | Situation | Why it happens | What to do | |---|---|---| -| New boxes are refused while your existing boxes keep running fine | You are at your plan's concurrency limit — the cap is on boxes running at the same time | Stop or remove a box you no longer need to free a slot, or move to a plan with a higher concurrency limit | -| Your cost is showing up under **From wallet** instead of **Quota-covered** | The included quota for this billing cycle is consumed, so usage now draws on your prepaid balance | Nothing is broken. Keep the wallet funded, or move to a plan whose included quota matches your steady-state usage | -| Wallet balance has reached zero and your quota is consumed | There is nothing left to fund usage this cycle | Click **Top up** on the **Wallet** tab, or redeem a coupon code under **REDEEM COUPON** | -| **ACTIVE PLAN** shows `No plan` | The account carries no subscription, so there is no included quota and no plan concurrency limit | Choose a plan from **ALL PLANS** on the **Overview** tab, or keep running on wallet balance alone if that suits you | -| **PAYMENT METHOD** shows `Payment method not connected` | No card is attached to the account | Click **Connect** on the **Wallet** tab. The console offers additional credits for connecting a credit card | -| A box needs more than 4 vCPU, 32 GiB of memory, or 120 GiB of storage | Those are per-box ceilings for the whole organization | Split the work across several boxes within your concurrency limit, or discuss custom limits with [sales@boxlite.ai](mailto:sales@boxlite.ai) | +| Wallet balance has reached zero and your quota is consumed | There is nothing left to fund usage this cycle | Top up on the **Wallet** tab, or redeem a coupon code | +| Boxes stopped and the console reports credits depleted | Nothing remains to fund them, so the platform stops boxes rather than running up an unfunded bill | Top up, then set automatic reload so it does not recur | +| Automatic reload is configured but never fires | Reload needs a card to charge, and no payment method is connected | Connect a card on the **Wallet** tab | +| The available balance drops while you have taken no payment | The figure is the ongoing balance, which already includes usage accrued but not yet settled | Nothing is wrong. Compare against the **Usage** tab for settled cost | +| The payment method shows as not connected | No card is attached to the account | Click **Connect** on the **Wallet** tab. Connecting one also earns $100 of credits | +| A box request asks for more CPU, memory, or disk than allowed | It exceeds a per-box ceiling for the organization | Lower the request, or spread the work across several boxes within your concurrency limit | +| Cost is higher than expected with no obvious cause | Stopped boxes are still billed for their disk until they are deleted | Delete boxes you have finished with. See [What each box state costs](/cloud/box-costs#what-each-box-state-costs) | + +## Next steps + + + + Rates, worked examples, and the two controls that cap what you keep paying. + + + The usage level at which a subscription starts costing less. + + diff --git a/cloud/box-costs.mdx b/cloud/box-costs.mdx new file mode 100644 index 0000000..857a5ea --- /dev/null +++ b/cloud/box-costs.mdx @@ -0,0 +1,98 @@ +--- +title: "What a box costs" +sidebarTitle: "Box costs" +description: "The three metered rates, the formula they combine into, what the standard sizes come to per hour and per month, and exactly which hours are counted." +--- + +A box rents three things by the hour: vCPU, memory, and disk. This page turns those rates into the numbers you can plan against. + +## Rates + +| Resource | Unit | Rate | +|---|---|---| +| vCPU | per vCPU-hour | **$0.0504** | +| Memory | per GiB-hour | **$0.0144** | +| Disk | per GiB-hour | **$0.00018** | + +A box's hourly price is the plain sum, with no tiers and no rounding: + +``` +hourly = vCPU × $0.0504 + memory_GiB × $0.0144 + disk_GiB × $0.00018 +``` + +Nothing else is metered. There is no charge for network traffic, API requests, mounted volumes, preview URLs, or the time a box spends booting. + +## What the standard sizes cost + +The three sizes in the **New Box** dialog, plus the largest box your organization can request: + +| Size | vCPU / memory / disk | Per hour | 30 days running | 30 days stopped | +|---|---|---|---|---| +| **Small** | 1 / 1 GiB / 10 GiB | $0.0666 | $47.95 | $1.30 | +| **Medium** | 2 / 4 GiB / 20 GiB | $0.1620 | $116.64 | $2.59 | +| **Large** | 4 / 8 GiB / 50 GiB | $0.3258 | $234.58 | $6.48 | +| Organization ceiling | 4 / 32 GiB / 120 GiB | $0.6840 | $492.48 | $15.55 | + +Two readings of that table matter. + +**Per invocation, boxes are almost free.** A 30-second code-interpreter run on a Small box costs $0.00056 — ten thousand of them come to $5.55. Per-run cost is not where Cloud bills get large. + +**Per month left running, they are not.** The gap between the last two columns is the whole cost story, and the next section is why it exists. [Cost controls](/cloud/cost-controls) is how you close it. + +To make a box cheaper, cut vCPU first: one vCPU-hour costs as much as **3.5 GiB-hours of memory** or **280 GiB-hours of disk**. Trimming disk is not worth the thought — it is 2–3% of a running box. + +## What each box state costs + +| Box state | vCPU | Memory | Disk | +|---|---|---|---| +| **Running** | charged | charged | charged | +| **Stopped** | — | — | **charged** | +| Creating or starting | — | — | — | +| **Deleted** | — | — | — | + +A stopped box has given back its compute but still occupies its disk, and it is billed for that disk until the box is deleted. + +One forgotten box is loose change. A hundred of them is $130–$650 a month for nothing, and nothing clears them for you. + +### How the hours are counted + +Compute and disk are charged over different intervals. The two clocks start together and stop at different moments: + +| Moment | vCPU + memory | Disk | +|---|---|---| +| Box created but never started | not charged | not charged | +| Box reaches **running** | clock starts | clock starts | +| You **request a stop** | **clock stops** | keeps running | +| Box is stopped, however long | not charged | keeps running | +| You **request deletion** | not charged | **clock stops** | + +Two of those moments are earlier than you might expect, and both are in your favour: + +- **Compute stops billing when the stop is requested**, not when the box has finished shutting down. You do not pay vCPU or memory for the shutdown itself. +- **Disk stops billing when deletion is requested**, not when teardown completes. + +There is **no minimum billable duration and no rounding up to a whole hour**. The platform records the exact moment of each transition, so a box that runs for four minutes is charged for four minutes at the hourly rate. A box you create and never start costs nothing at all. + +## Estimates and settlement + +The **New Box** dialog quotes what your chosen size costs to run for one hour. Treat it as an estimate — your actual charge is settled from recorded usage, and the **Usage** tab is the authority on what you were charged. See [Managing billing](/cloud/billing#read-the-usage-chart). + +## Troubleshooting + +| Situation | Why it happens | What to do | +|---|---|---| +| A box you stopped days ago is still adding to your bill | A stopped box keeps its disk and is charged for it until deleted | Delete it, or set `auto_delete` so it clears itself. See [Cost controls](/cloud/cost-controls) | +| Cost is far higher than the size suggests | Something keeps the box busy, so it never goes idle and `auto_stop` never fires. A busy box is not an idle box | Check what is running inside it, or stop the box explicitly when the work finishes | +| Creation fails with `auto_delete must be greater than auto_stop` | Both are non-zero but `auto_delete` is not larger | Raise `auto_delete` above `auto_stop`, or set `auto_delete=0` to disable deletion | +| A request for more than 4 vCPU, 32 GiB of memory, or 120 GiB of disk is rejected | Those are per-box ceilings for the whole organization, enforced before the box is created | Lower the request or split the work across boxes. See [Per-box resource ceilings](/cloud/billing#per-box-resource-ceilings) | + +## Next steps + + + + The two lifecycle controls that stop a finished box from charging you. + + + The usage level at which a subscription starts costing less. + + diff --git a/cloud/box-from-code.mdx b/cloud/box-from-code.mdx new file mode 100644 index 0000000..d4108c7 --- /dev/null +++ b/cloud/box-from-code.mdx @@ -0,0 +1,115 @@ +--- +title: "Create, reuse, and remove a box from code" +sidebarTitle: "From code" +description: "The full create-start-use-remove cycle over REST, reusing a box by name, managing one from the console, and passing environment variables." +--- + +One runnable script for the whole cycle, plus the two things people get wrong: reusing a box by name, and tearing it down when an exception fires. + +## Prerequisites + +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). + +## Create, reuse, and remove a box from code + +Name your box. The name is how a second process — a worker, a retry, tomorrow's cron job — finds the same box instead of building a new one. + +`name` is a parameter of `rt.create(...)`, not a field of `BoxOptions`. Passing `name=` inside `BoxOptions` fails at construction. + +```python +import asyncio +import os + +from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions + +BOX_NAME = "nightly-report-runner" + +async def main() -> None: + box_id = None + rt = None + try: + # export BOXLITE_API_KEY= before running + rt = Boxlite.rest(BoxliteRestOptions( + url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), + credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), + )) + + box = await rt.create( + BoxOptions(image="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"), + name=BOX_NAME, + ) + box_id = box.id + await box.start() + + # REST boxes take command arguments as a list + execution = await box.exec("sh", args=["-c", "echo report ready"]) + output = "" + async for line in execution.stdout(): + output += line + result = await execution.wait() + print(f"exit code: {result.exit_code}") + print(output) + + # any process holding your API key can pick the same box up by name + again = await rt.get(BOX_NAME) + if again is None: + raise RuntimeError(f"box {BOX_NAME} not found") + print(f"reused box: {again.id}") + except Exception as exc: + print(f"cloud box failed: {type(exc).__name__}: {exc}") + finally: + # remove is called on the runtime, so this script can be run again with the same name + if rt is not None and box_id: + try: + await rt.remove(box_id, force=True) + except Exception as exc: + print(f"remove failed: {exc}") + +if __name__ == "__main__": + asyncio.run(main()) +``` + +Three shapes to keep in mind on Cloud: + +- `Boxlite.rest(...)` is constructed synchronously; `create`, `start`, `exec`, and `remove` are all awaited. +- `box.exec("echo", args=["hi"])` takes its arguments as a list. +- Teardown is `await rt.remove(box.id, force=True)` on the runtime. + +`rt.get_or_create(...)` creates a box or reuses an existing one with the same name in a single call. For the state model behind create, start, stop, and remove — and for the signatures of the runtime methods — see [Lifecycle](/manage-sandbox/lifecycle). + +## Manage a box from the console + +The **Boxes** list is the management surface for a box, so you do not need your own tooling to see what you are running. Each row shows the box name, its id, and its status, and carries two actions: + +| Action | What it does | +|---|---| +| **Stop** | Stops a running box, keeping its disk | +| **More** → **Delete** | Removes the box, after a confirmation that warns the action cannot be undone | + +BoxLite generates a name for a box you create in the console — a two-word pair such as `golden-lynx` — and a short mixed-case id such as `9z8vat0excp9`. When you create a box from code you pass your own name, which is what makes a box findable later. + +Use the list to catch boxes a crashed script left behind. Filter by name, check which are still `RUNNING`, and stop or delete them. + +## Environment variables and secrets + +`BoxOptions` accepts `env` for plain configuration and `secrets` for values that should not sit in your box's environment or logs. Their shapes differ between Python and Node, and secrets carry extra options such as host scoping, so use the pages that own those tables: [Environment and startup](/manage-sandbox/environment) and [Inject secrets and harden a box](/manage-sandbox/secrets-and-security). + +Keep your BoxLite API key out of both. It belongs in the environment of the process that calls `Boxlite.rest(...)`, not inside the box. + + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `BoxOptions(name="my-box")` fails with a no-such-field error | `name` belongs to the create call | Use `await rt.create(BoxOptions(...), name="my-box")` | +| `box.exec("sh", "-c", "echo hi")` fails on Cloud | REST boxes take arguments as a list | Use `box.exec("sh", args=["-c", "echo hi"])` | +| `await box.remove()` raises an attribute error | Removal happens on the runtime | Use `await rt.remove(box.id, force=True)` | +| A box is still billing after your script crashed | The script died before its cleanup ran | Open the **Boxes** list and stop or delete the leftovers, or list them from code with `await rt.list_info()` and remove each with `await rt.remove(box_id, force=True)`. Setting **Delete after stopping** on future boxes lets the platform reclaim them for you | +## Next steps + + + + Everything else about configuring a box on Cloud. + + diff --git a/cloud/box-images.mdx b/cloud/box-images.mdx new file mode 100644 index 0000000..c4b2506 --- /dev/null +++ b/cloud/box-images.mdx @@ -0,0 +1,85 @@ +--- +title: "Choose an image for a Cloud box" +sidebarTitle: "Images" +description: "The three images the console offers, what an image reference looks like from code, and which one to start from." +--- + +A box starts from an image. Pick the one that already has your runtime rather than installing it on every start. + +## Prerequisites + +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). + +## Choose an image + +The **New Box** dialog offers three images: + +| Console option | Use it for | +|---|---| +| **Base** | A general-purpose Linux box you install into yourself | +| **Python** | Python workloads without a build step | +| **Node.js** | Node workloads without a build step | + +From code you pass an image reference instead. The official SDK examples use `ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0`, and that is the image to start from when you have no reason to pick another: + +```python +import asyncio +import os +import time + +from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions + +async def main() -> None: + box_id = None + rt = None + try: + # export BOXLITE_API_KEY= before running + rt = Boxlite.rest(BoxliteRestOptions( + url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), + credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), + )) + + box = await rt.create( + BoxOptions(image="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"), + name=f"image-check-{int(time.time())}", + ) + box_id = box.id + await box.start() + + execution = await box.exec("cat", args=["/etc/os-release"]) + output = "" + async for line in execution.stdout(): + output += line + result = await execution.wait() + + print(f"exit code: {result.exit_code}") + print(output) + except Exception as exc: + print(f"box failed: {type(exc).__name__}: {exc}") + finally: + if rt is not None and box_id: + try: + await rt.remove(box_id, force=True) + except Exception as exc: + print(f"remove failed: {exc}") + +if __name__ == "__main__": + asyncio.run(main()) +``` + +An API key carries the `Boxes` permission, and the console describes what that includes: + +> Boxes API access — This key can create and manage Boxes. Shared Linux base images are available automatically. + +So you do not stage or pull a base image before your first `create`. Image operations such as pulling are not supported over the REST runtime — see [Cloud versus open source](/cloud/vs-opensource). + + + +## Next steps + + + + Everything else about configuring a box on Cloud. + + diff --git a/cloud/box-lifecycle.mdx b/cloud/box-lifecycle.mdx new file mode 100644 index 0000000..abf54c4 --- /dev/null +++ b/cloud/box-lifecycle.mdx @@ -0,0 +1,115 @@ +--- +title: "Box lifecycle on Cloud" +sidebarTitle: "Lifecycle" +description: "The three controls that decide when a Cloud box stops, whether it wakes again, and when it is deleted — and the idle rule that can end a job you thought was safe." +--- + +A Cloud box does not run until you stop it. The platform stops it when it looks idle, wakes it when you reach for it, and can delete it once it has been stopped for a while. These three controls are the most consequential settings on a box. + +## Prerequisites + +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). + +## Lifecycle on Cloud + +Three controls in the **New Box** dialog govern how long your box lives. They behave differently from a box you run yourself, and the first one can end a job you thought was safe. + +| Control | Choices | Default | +|---|---|---| +| **Stop when idle** | `Never`, `5 min`, `15 min`, `30 min`, `1 hour`, `4 hours`, or a custom value | `15 min` | +| **Wake on access** | A switch | On | +| **Delete after stopping** | A delay, or `Never` to keep the box | `Never` | + +Set them in the console when you create a box there, or from code with three `BoxOptions` fields: + +| Field | Type | Meaning | +|---|---|---| +| `auto_stop` | `int` (seconds) | Idle time before the box is stopped. `0` disables it | +| `auto_delete` | `int` (seconds) | Time spent stopped before the box is deleted. `0` disables it | +| `auto_resume` | `bool` | Whether an incoming operation resumes the box after an auto-stop | + +Two rules to know before you set them: + +- **`auto_delete` must be greater than `auto_stop`** when both are non-zero. Otherwise creation fails with `auto_delete must be greater than auto_stop`, because a box that deletes itself before it stops has no reachable state. +- **The defaults come from the platform, not from the SDK.** Omit these fields and the SDK leaves them out of the request entirely, so the platform applies its own defaults: `auto_stop=900` (15 idle minutes), `auto_delete=0` (never), and `auto_resume=true`. A box created from code therefore behaves like one created in the console — **it will stop itself after 15 idle minutes whether you asked for that or not.** + +The console's `15 min` corresponds to `auto_stop=900`, which is the same value a box created from code receives by default. To keep a box running through unattended work, you must pass `auto_stop` explicitly — see [Stop when idle](#stop-when-idle). + +The field names matter: `idle_timeout`, `stop_when_idle`, `wake_on_access`, `delete_after_stopping`, and `auto_pause` are not fields of `BoxOptions` and each fails construction with a no-such-field error. + +## Stop when idle + +Idleness is measured at the boundary of the box, not inside it. The console is explicit: + +> Idle means no SDK, terminal or preview traffic. Work running inside the box does not count — a long job can be stopped mid-run. + +That is the single most important sentence on this page. A 40-minute build that you kick off and then stop talking to looks idle from the outside, and the platform stops it at 15 minutes with the build half-finished. + +Two ways to keep a long job alive: + +- **Keep touching the box from your client.** Poll the job while it runs — read its progress file or check its process — so real SDK traffic keeps arriving. +- **Raise the idle timeout, or disable it.** In the console, pick a longer value or `Never`; from code, pass a larger `auto_stop` or `auto_stop=0`. Disabling it is the right choice for a box whose whole purpose is unattended work — and the one that makes you responsible for tearing it down. + +```python +import asyncio + +async def run_long_job(box) -> int: + """Start a long job, then poll it so the box keeps seeing SDK traffic.""" + starter = await box.exec("sh", args=["-c", "nohup ./build.sh > /tmp/build.log 2>&1 & echo started"]) + await starter.wait() + + while True: + # each exec is SDK traffic, so the box does not look idle + probe = await box.exec("sh", args=["-c", "pgrep -f build.sh > /dev/null && echo running || echo done"]) + state = "" + async for line in probe.stdout(): + state += line + await probe.wait() + + if "done" in state: + break + await asyncio.sleep(60) + + tail = await box.exec("tail", args=["-n", "20", "/tmp/build.log"]) + async for line in tail.stdout(): + print(line, end="") + result = await tail.wait() + return result.exit_code +``` + + +Pass this helper a box you already created and started, using the pattern in [Create, reuse, and remove a box from code](/cloud/box-from-code). Choose a poll interval comfortably shorter than the box's idle timeout. + + +## Wake on access + +Resume is on by default — the console switch starts on, and `auto_resume` defaults to `true` over the API. With it enabled, a stopped box comes back on its own when you reach for it: + +> SDK exec, file operations and terminal attach wake a stopped box. Preview URL traffic keeps a running box alive but cannot wake a stopped one. + +This is what makes a named Cloud box feel durable: your script calls `exec` after a weekend, the box restarts with its disk intact, and your code carries on. Enable it for any box you intend to reuse. Leave it off when a stop should be final until you intervene. + +## Delete after stopping + +**Delete after stopping** defaults to `Never`, so a stopped box keeps its disk and stays addressable by name. Set a delay when you want the platform to reclaim throwaway boxes for you instead of remembering to call `remove` yourself. + + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| A long job dies partway through, around the 15-minute mark | **Stop when idle** counts only SDK, terminal, and preview traffic. Work inside the box does not count, so the platform stopped the box mid-run | Poll the box from your client while the job runs, or raise or disable the idle timeout for that box in the console | +| `exec` against a stopped box fails instead of restarting it | Resume was switched off for that box. It is on by default in both the console and over the API, so this only happens if it was turned off at creation | Recreate the box with **Wake on access** left on, or `auto_resume=True` from code — that is what lets SDK exec, file operations, and terminal attach bring a stopped box back | +| `BoxOptions(idle_timeout=3600)` or `BoxOptions(auto_pause=True)` fails with a no-such-field error | Those are not the field names | Use `auto_stop`, `auto_delete`, and `auto_resume` — see [Lifecycle on Cloud](/cloud/box-lifecycle) | +| Creation fails with `auto_delete must be greater than auto_stop` | `auto_delete` is non-zero but not larger than `auto_stop` | Raise `auto_delete` above `auto_stop`, or set `auto_delete=0` to disable deletion | +| A box created from code stopped in the middle of unattended work | Omitting `auto_stop` does not disable it — the platform applies its `900` second default, and work inside the box does not count as activity | Pass `auto_stop=0` to disable auto-stop, or a value longer than the job. See [Stop when idle](#stop-when-idle) | +| A box you expected to keep running was gone or stopped after 15 minutes | Same cause: `auto_stop` defaults to `900`, not to `0` | Set `auto_stop` explicitly for any box doing unattended work | +| A box you expected to reuse is gone | Its **Delete after stopping** delay elapsed after an idle stop | Create the replacement with **Delete after stopping** set to `Never`, and put anything you must keep on a volume — see [Volumes on Cloud](/cloud/volumes) | +## Next steps + + + + Everything else about configuring a box on Cloud. + + diff --git a/cloud/box-sizes.mdx b/cloud/box-sizes.mdx new file mode 100644 index 0000000..d87a6ec --- /dev/null +++ b/cloud/box-sizes.mdx @@ -0,0 +1,62 @@ +--- +title: "Choose a size for a Cloud box" +sidebarTitle: "Sizes" +description: "The three preset sizes, the per-organization ceilings that bound any single box, and the SDK fields that set them." +--- + +Size is vCPU, memory, and disk. Pick the smallest preset that runs your workload comfortably — it is what you pay for by the hour. + +## Prerequisites + +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). + +## Choose a size + +The console offers three presets and a custom option: + +| Size | vCPU | Memory | Disk | +|---|---|---|---| +| **Small** | 1 | 1 GiB | 10 GiB | +| **Medium** | 2 | 4 GiB | 20 GiB | +| **Large** | 4 | 8 GiB | 50 GiB | +| **Custom** | Your value | Your value, in GiB | Your value, in GiB | + +Pick **Small** for shell work and short scripts, **Medium** for test suites and dependency installs, **Large** for builds and anything that keeps several processes hot. + +## Per-organization ceilings + +**Custom** is bounded. Your organization has one ceiling per box: + +| Resource | Ceiling per box | +|---|---| +| Compute | 4 vCPU | +| Memory | 32 GiB | +| Storage | 120 GiB | + +The console shows these under **Box limits** on the Billing page, with the reason: + +> Limits mitigate misuse and keep box and compute capacity fairly available across all users. + +Read your current ceilings in [Managing billing](/cloud/billing#per-box-resource-ceilings). Split a workload that wants more than one box can hold across several boxes rather than trying to raise a single box past the ceiling. + +## The SDK's sizing fields + +`BoxOptions` accepts `cpus`, `memory_mib`, and `disk_size_gb`. Their types, units, and general behaviour live in [Compute resources](/manage-sandbox/compute-resources) — the console sizes above are the verified way to size a Cloud box, so set the size in the console when you want a specific shape. + +The disk field is named `disk_size_gb`. `disk_gib` is a common guess and fails at construction with a no-such-field error. + + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `BoxOptions(disk_gib=20)` fails with a no-such-field error | The field is `disk_size_gb` | Use `BoxOptions(disk_size_gb=20)`. See [Compute resources](/manage-sandbox/compute-resources) | +| A box request asks for more CPU, memory, or disk than your organization allows | Ceilings are 4 vCPU compute, 32 GiB memory, and 120 GiB storage per box | Lower the request, or spread the work across several boxes. Check your ceilings in [Managing billing](/cloud/billing#per-box-resource-ceilings) | +## Next steps + + + + Everything else about configuring a box on Cloud. + + diff --git a/cloud/boxes.mdx b/cloud/boxes.mdx index b5b9f7f..0bbde76 100644 --- a/cloud/boxes.mdx +++ b/cloud/boxes.mdx @@ -1,298 +1,41 @@ --- -title: "Configure a box on BoxLite Cloud" +title: "Boxes" sidebarTitle: "Boxes" -description: "Pick an image and a size for a Cloud box, and understand the three lifecycle controls the platform applies while it runs." +description: "A box on BoxLite Cloud is a microVM you address by name or id from anywhere your API key reaches — choose its image and size, and know when the platform stops it." --- A box on BoxLite Cloud is a microVM you address by name or id from anywhere your API key reaches. You choose its image and its size when you create it, and the platform decides when it stops and whether it wakes again. -## Prerequisites - -- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). -- `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). - -Every example below reads both values from the environment, so nothing in your code hard-codes a credential. - -## Choose an image - -The **New Box** dialog offers three images: - -| Console option | Use it for | -|---|---| -| **Base** | A general-purpose Linux box you install into yourself | -| **Python** | Python workloads without a build step | -| **Node.js** | Node workloads without a build step | - -From code you pass an image reference instead. The official SDK examples use `ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0`, and that is the image to start from when you have no reason to pick another: - -```python -import asyncio -import os -import time - -from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions - -async def main() -> None: - box_id = None - rt = None - try: - # export BOXLITE_API_KEY= before running - rt = Boxlite.rest(BoxliteRestOptions( - url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), - credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), - )) - - box = await rt.create( - BoxOptions(image="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"), - name=f"image-check-{int(time.time())}", - ) - box_id = box.id - await box.start() - - execution = await box.exec("cat", args=["/etc/os-release"]) - output = "" - async for line in execution.stdout(): - output += line - result = await execution.wait() - - print(f"exit code: {result.exit_code}") - print(output) - except Exception as exc: - print(f"box failed: {type(exc).__name__}: {exc}") - finally: - if rt is not None and box_id: - try: - await rt.remove(box_id, force=True) - except Exception as exc: - print(f"remove failed: {exc}") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -An API key carries the `Boxes` permission, and the console describes what that includes: - -> Boxes API access — This key can create and manage Boxes. Shared Linux base images are available automatically. - -So you do not stage or pull a base image before your first `create`. Image operations such as pulling are not supported over the REST runtime — see [Cloud versus open source](/cloud/vs-opensource). - -## Choose a size - -The console offers three presets and a custom option: - -| Size | vCPU | Memory | Disk | -|---|---|---|---| -| **Small** | 1 | 1 GiB | 10 GiB | -| **Medium** | 2 | 4 GiB | 20 GiB | -| **Large** | 4 | 8 GiB | 50 GiB | -| **Custom** | Your value | Your value, in GiB | Your value, in GiB | - -Pick **Small** for shell work and short scripts, **Medium** for test suites and dependency installs, **Large** for builds and anything that keeps several processes hot. - -### Per-organization ceilings - -**Custom** is bounded. Your organization has one ceiling per box: - -| Resource | Ceiling per box | -|---|---| -| Compute | 4 vCPU | -| Memory | 32 GiB | -| Storage | 120 GiB | - -The console shows these under **Box limits** on the Billing page, with the reason: - -> Limits mitigate misuse and keep box and compute capacity fairly available across all users. - -Read your current ceilings in [Plans, wallet, and usage](/cloud/billing). Split a workload that wants more than one box can hold across several boxes rather than trying to raise a single box past the ceiling. - -### The SDK's sizing fields - -`BoxOptions` accepts `cpus`, `memory_mib`, and `disk_size_gb`. Their types, units, and general behaviour live in [Compute resources](/manage-sandbox/compute-resources) — the console sizes above are the verified way to size a Cloud box, so set the size in the console when you want a specific shape. - -The disk field is named `disk_size_gb`. `disk_gib` is a common guess and fails at construction with a no-such-field error. - -## Lifecycle on Cloud - -Three controls in the **New Box** dialog govern how long your box lives. They behave differently from a box you run yourself, and the first one can end a job you thought was safe. - -| Control | Choices | Default | -|---|---|---| -| **Stop when idle** | `Never`, `5 min`, `15 min`, `30 min`, `1 hour`, `4 hours`, or a custom value | `15 min` | -| **Wake on access** | A switch | On | -| **Delete after stopping** | A delay, or `Never` to keep the box | `Never` | - -Set them in the console when you create a box there, or from code with three `BoxOptions` fields: - -| Field | Type | Meaning | -|---|---|---| -| `auto_stop` | `int` (seconds) | Idle time before the box is stopped. `0` disables it | -| `auto_delete` | `int` (seconds) | Time spent stopped before the box is deleted. `0` disables it | -| `auto_resume` | `bool` | Whether an incoming operation resumes the box after an auto-stop | - -Two rules to know before you set them: - -- **`auto_delete` must be greater than `auto_stop`** when both are non-zero. Otherwise creation fails with `auto_delete must be greater than auto_stop`, because a box that deletes itself before it stops has no reachable state. -- **The code defaults are not the console defaults.** Leave these fields unset and a box created over REST gets `auto_stop=0` and `auto_delete=0` — no auto-stop and no auto-delete at all — with `auto_resume` defaulting to `true`. The console's `15 min` idle default applies to boxes you create in the console. So a box created from code keeps running until you stop it, and paying for it is your responsibility. - -The console's `15 min` corresponds to `auto_stop=900`. - -The field names matter: `idle_timeout`, `stop_when_idle`, `wake_on_access`, `delete_after_stopping`, and `auto_pause` are not fields of `BoxOptions` and each fails construction with a no-such-field error. - -### Stop when idle - -Idleness is measured at the boundary of the box, not inside it. The console is explicit: - -> Idle means no SDK, terminal or preview traffic. Work running inside the box does not count — a long job can be stopped mid-run. - -That is the single most important sentence on this page. A 40-minute build that you kick off and then stop talking to looks idle from the outside, and the platform stops it at 15 minutes with the build half-finished. - -Two ways to keep a long job alive: - -- **Keep touching the box from your client.** Poll the job while it runs — read its progress file or check its process — so real SDK traffic keeps arriving. -- **Raise the idle timeout, or disable it.** In the console, pick a longer value or `Never`; from code, pass a larger `auto_stop` or `auto_stop=0`. Disabling it is the right choice for a box whose whole purpose is unattended work — and the one that makes you responsible for tearing it down. - -```python -import asyncio - -async def run_long_job(box) -> int: - """Start a long job, then poll it so the box keeps seeing SDK traffic.""" - starter = await box.exec("sh", args=["-c", "nohup ./build.sh > /tmp/build.log 2>&1 & echo started"]) - await starter.wait() - - while True: - # each exec is SDK traffic, so the box does not look idle - probe = await box.exec("sh", args=["-c", "pgrep -f build.sh > /dev/null && echo running || echo done"]) - state = "" - async for line in probe.stdout(): - state += line - await probe.wait() - - if "done" in state: - break - await asyncio.sleep(60) - - tail = await box.exec("tail", args=["-n", "20", "/tmp/build.log"]) - async for line in tail.stdout(): - print(line, end="") - result = await tail.wait() - return result.exit_code -``` - - -Pass this helper a box you already created and started, using the pattern in [Create, reuse, and remove a box from code](#create-reuse-and-remove-a-box-from-code). Choose a poll interval comfortably shorter than the box's idle timeout. - - -### Wake on access - -Resume is on by default — the console switch starts on, and `auto_resume` defaults to `true` over the API. With it enabled, a stopped box comes back on its own when you reach for it: - -> SDK exec, file operations and terminal attach wake a stopped box. Preview URL traffic keeps a running box alive but cannot wake a stopped one. - -This is what makes a named Cloud box feel durable: your script calls `exec` after a weekend, the box restarts with its disk intact, and your code carries on. Enable it for any box you intend to reuse. Leave it off when a stop should be final until you intervene. - -### Delete after stopping - -**Delete after stopping** defaults to `Never`, so a stopped box keeps its disk and stays addressable by name. Set a delay when you want the platform to reclaim throwaway boxes for you instead of remembering to call `remove` yourself. - -## Create, reuse, and remove a box from code - -Name your box. The name is how a second process — a worker, a retry, tomorrow's cron job — finds the same box instead of building a new one. - -`name` is a parameter of `rt.create(...)`, not a field of `BoxOptions`. Passing `name=` inside `BoxOptions` fails at construction. - -```python -import asyncio -import os - -from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions - -BOX_NAME = "nightly-report-runner" - -async def main() -> None: - box_id = None - rt = None - try: - # export BOXLITE_API_KEY= before running - rt = Boxlite.rest(BoxliteRestOptions( - url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), - credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), - )) - - box = await rt.create( - BoxOptions(image="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"), - name=BOX_NAME, - ) - box_id = box.id - await box.start() - - # REST boxes take command arguments as a list - execution = await box.exec("sh", args=["-c", "echo report ready"]) - output = "" - async for line in execution.stdout(): - output += line - result = await execution.wait() - print(f"exit code: {result.exit_code}") - print(output) - - # any process holding your API key can pick the same box up by name - again = await rt.get(BOX_NAME) - if again is None: - raise RuntimeError(f"box {BOX_NAME} not found") - print(f"reused box: {again.id}") - except Exception as exc: - print(f"cloud box failed: {type(exc).__name__}: {exc}") - finally: - # remove is called on the runtime, so this script can be run again with the same name - if rt is not None and box_id: - try: - await rt.remove(box_id, force=True) - except Exception as exc: - print(f"remove failed: {exc}") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -Three shapes to keep in mind on Cloud: - -- `Boxlite.rest(...)` is constructed synchronously; `create`, `start`, `exec`, and `remove` are all awaited. -- `box.exec("echo", args=["hi"])` takes its arguments as a list. -- Teardown is `await rt.remove(box.id, force=True)` on the runtime. - -`rt.get_or_create(...)` creates a box or reuses an existing one with the same name in a single call. For the state model behind create, start, stop, and remove — and for the signatures of the runtime methods — see [Lifecycle](/manage-sandbox/lifecycle). - -## Manage a box from the console - -The **Boxes** list is the management surface for a box, so you do not need your own tooling to see what you are running. Each row shows the box name, its id, and its status, and carries two actions: - -| Action | What it does | + +**A Cloud box stops itself after 15 idle minutes by default**, and work running *inside* the box does not count as activity. A long unattended job can be stopped mid-run. See [Lifecycle](/cloud/box-lifecycle#stop-when-idle) before you start one. + + +## In this section + + + + The three images the console offers, and what an image reference looks like from code. + + + The preset sizes, the per-box ceilings for your organization, and the SDK sizing fields. + + + Stop when idle, wake on access, delete after stopping. **The settings most likely to surprise you.** + + + The full create-start-use-remove cycle, reusing a box by name, and environment variables. + + + +## Which page do I want? + +| You are asking | Page | |---|---| -| **Stop** | Stops a running box, keeping its disk | -| **More** → **Delete** | Removes the box, after a confirmation that warns the action cannot be undone | - -BoxLite generates a name for a box you create in the console — a two-word pair such as `golden-lynx` — and a short mixed-case id such as `9z8vat0excp9`. When you create a box from code you pass your own name, which is what makes a box findable later. - -Use the list to catch boxes a crashed script left behind. Filter by name, check which are still `RUNNING`, and stop or delete them. - -## Environment variables and secrets - -`BoxOptions` accepts `env` for plain configuration and `secrets` for values that should not sit in your box's environment or logs. Their shapes differ between Python and Node, and secrets carry extra options such as host scoping, so use the pages that own those tables: [Environment and startup](/manage-sandbox/environment) and [Inject secrets and harden a box](/manage-sandbox/secrets-and-security). - -Keep your BoxLite API key out of both. It belongs in the environment of the process that calls `Boxlite.rest(...)`, not inside the box. - -## Troubleshooting - -| Symptom | Cause | Fix | -|---|---|---| -| A long job dies partway through, around the 15-minute mark | **Stop when idle** counts only SDK, terminal, and preview traffic. Work inside the box does not count, so the platform stopped the box mid-run | Poll the box from your client while the job runs, or raise or disable the idle timeout for that box in the console | -| `exec` against a stopped box fails instead of restarting it | Resume was switched off for that box. It is on by default in both the console and over the API, so this only happens if it was turned off at creation | Recreate the box with **Wake on access** left on, or `auto_resume=True` from code — that is what lets SDK exec, file operations, and terminal attach bring a stopped box back | -| `BoxOptions(disk_gib=20)` fails with a no-such-field error | The field is `disk_size_gb` | Use `BoxOptions(disk_size_gb=20)`. See [Compute resources](/manage-sandbox/compute-resources) | -| `BoxOptions(name="my-box")` fails with a no-such-field error | `name` belongs to the create call | Use `await rt.create(BoxOptions(...), name="my-box")` | -| `BoxOptions(idle_timeout=3600)` or `BoxOptions(auto_pause=True)` fails with a no-such-field error | Those are not the field names | Use `auto_stop`, `auto_delete`, and `auto_resume` — see [Lifecycle on Cloud](#lifecycle-on-cloud) | -| Creation fails with `auto_delete must be greater than auto_stop` | `auto_delete` is non-zero but not larger than `auto_stop` | Raise `auto_delete` above `auto_stop`, or set `auto_delete=0` to disable deletion | -| A box created from code never stops on its own | Left unset, `auto_stop` defaults to `0`, which disables auto-stop. The console's `15 min` default does not apply to boxes created over the API | Pass `auto_stop` explicitly, for example `auto_stop=900` for 15 minutes | -| A box request asks for more CPU, memory, or disk than your organization allows | Ceilings are 4 vCPU compute, 32 GiB memory, and 120 GiB storage per box | Lower the request, or spread the work across several boxes. Check your ceilings in [Plans, wallet, and usage](/cloud/billing) | -| `box.exec("sh", "-c", "echo hi")` fails on Cloud | REST boxes take arguments as a list | Use `box.exec("sh", args=["-c", "echo hi"])` | -| `await box.remove()` raises an attribute error | Removal happens on the runtime | Use `await rt.remove(box.id, force=True)` | -| A box is still billing after your script crashed | The script died before its cleanup ran | Open the **Boxes** list and stop or delete the leftovers, or list them from code with `await rt.list_info()` and remove each with `await rt.remove(box_id, force=True)`. Setting **Delete after stopping** on future boxes lets the platform reclaim them for you | -| A box you expected to reuse is gone | Its **Delete after stopping** delay elapsed after an idle stop | Create the replacement with **Delete after stopping** set to `Never`, and put anything you must keep on a volume — see [Volumes on Cloud](/cloud/volumes) | +| Which image should I start from? | [Images](/cloud/box-images) | +| How much CPU and memory can one box have? | [Sizes](/cloud/box-sizes#per-organization-ceilings) | +| Why did my box stop on its own? | [Lifecycle](/cloud/box-lifecycle#stop-when-idle) | +| How do I keep a box alive through a long job? | [Lifecycle](/cloud/box-lifecycle#stop-when-idle) | +| How do I get a box deleted automatically? | [Lifecycle](/cloud/box-lifecycle#delete-after-stopping) | +| Show me the whole cycle in one script | [From code](/cloud/box-from-code) | +| How do I reuse a box I created earlier? | [From code](/cloud/box-from-code) | +| What does a box cost per hour? | [What a box costs](/cloud/box-costs) | diff --git a/cloud/cost-controls.mdx b/cloud/cost-controls.mdx new file mode 100644 index 0000000..652749c --- /dev/null +++ b/cloud/cost-controls.mdx @@ -0,0 +1,190 @@ +--- +title: "Stop paying for boxes you have finished with" +sidebarTitle: "Cost controls" +description: "The two lifecycle controls that decide how long each half of a box’s bill runs, and the volume pattern that keeps data without keeping a disk." +--- + +Compute billing stops on its own. Disk billing does not. These are the controls that close that second loop. + +Two lifecycle controls decide how long each half of the bill runs, and their defaults pull in opposite directions: + +| Control | Default | What it does to your bill | +|---|---|---| +| `auto_stop` | 900 seconds (15 minutes idle) | **Compute billing stops on its own.** After 15 idle minutes the box stops, and vCPU and memory come off the bill | +| `auto_delete` | `0`, disabled | **Disk billing never stops on its own.** A stopped box keeps its disk, and keeps being charged for it, indefinitely | + +| Control | Default | What it does to your bill | +|---|---|---| +| `auto_stop` | 900 seconds (15 minutes idle) | **Compute billing stops on its own.** After 15 idle minutes the box stops, and vCPU and memory come off the bill | +| `auto_delete` | `0`, disabled | **Disk billing never stops on its own.** A stopped box keeps its disk, and keeps being charged for it, indefinitely | + +So by default you stop paying for compute automatically, and you pay for disk forever. Setting `auto_delete` closes that second loop. + +It must be larger than `auto_stop` when both are non-zero. This example gives a box 15 idle minutes to be reused, then deletes it an hour after it stops: + + + +```python Python +# costCappedBox.py — create a box that cleans itself up +# Run: python costCappedBox.py +import asyncio +import os +import time + +from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions + +IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" + +async def main() -> None: + api_key = os.environ.get("BOXLITE_API_KEY") + if not api_key: + raise SystemExit("Set BOXLITE_API_KEY to your blk_live_... key first.") + + rt = Boxlite.rest(BoxliteRestOptions( + url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), + credential=ApiKeyCredential(api_key), + )) + + box = None + try: + box = await rt.create( + BoxOptions( + image=IMAGE, + auto_stop=900, # stop after 15 idle minutes -> compute billing ends + auto_delete=3600, # delete an hour after stopping -> disk billing ends + ), + name=f"capped-{int(time.time())}", + ) + await box.start() + print(f"box {box.id} will delete itself an hour after it stops") + except Exception as exc: + print(f"create failed: {type(exc).__name__}: {exc}") + if box is not None: + await rt.remove(box.id, force=True) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +```typescript Node.js +// costCappedBox.ts — create a box that cleans itself up +// Run: node costCappedBox.ts +import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; + +const IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"; + +const apiKey = process.env.BOXLITE_API_KEY; +if (!apiKey) { + throw new Error("Set BOXLITE_API_KEY to your blk_live_... key first."); +} + +async function main(): Promise { + const rt = JsBoxlite.rest( + new BoxliteRestOptions({ + url: process.env.BOXLITE_REST_URL ?? "https://app.boxlite.ai/api", + credential: new ApiKeyCredential(apiKey), + }), + ); + + let box = null; + try { + box = await rt.create( + { + image: IMAGE, + autoStop: 900, // stop after 15 idle minutes -> compute billing ends + autoDelete: 3600, // delete an hour after stopping -> disk billing ends + }, + `capped-${Math.floor(Date.now() / 1000)}`, + ); + await box.start(); + console.log(`box ${box.id} will delete itself an hour after it stops`); + } catch (err) { + console.error(`create failed: ${err}`); + if (box) { + await rt.remove(box.id, true); + } + } +} + +main(); +``` + +```go Go +// costCappedBox.go — create a box that cleans itself up +// Run: go run costCappedBox.go +package main + +import ( + "context" + "fmt" + "os" + "time" + + boxlite "github.com/boxlite-ai/boxlite/sdks/go" +) + +const image = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" + +func main() { + apiKey := os.Getenv("BOXLITE_API_KEY") + if apiKey == "" { + fmt.Println("Set BOXLITE_API_KEY to your blk_live_... key first.") + os.Exit(1) + } + + restURL := os.Getenv("BOXLITE_REST_URL") + if restURL == "" { + restURL = "https://app.boxlite.ai/api" + } + + rt, err := boxlite.NewRest(boxlite.BoxliteRestOptions{ + URL: restURL, + Credential: boxlite.NewApiKeyCredential(apiKey), + }) + if err != nil { + fmt.Printf("connect failed: %v\n", err) + os.Exit(1) + } + defer rt.Close() + + ctx := context.Background() + box, err := rt.Create(ctx, image, + boxlite.WithName(fmt.Sprintf("capped-%d", time.Now().Unix())), + boxlite.WithAutoStopInterval(900), // compute billing ends after 15 idle minutes + boxlite.WithAutoDeleteInterval(3600), // disk billing ends an hour after stopping + ) + if err != nil { + fmt.Printf("create failed: %v\n", err) + os.Exit(1) + } + + if err := box.Start(ctx); err != nil { + fmt.Printf("start failed: %v\n", err) + _ = rt.Remove(ctx, box.ID()) + os.Exit(1) + } + + fmt.Printf("box %s will delete itself an hour after it stops\n", box.ID()) +} +``` + + + +See [Lifecycle on Cloud](/cloud/box-lifecycle) for the full semantics of these fields, including how `auto_resume` brings a stopped box back. + +## Keep the data, drop the disk + +`auto_delete` is uncomfortable when the box holds something you need later. That is what volumes are for, and it happens to be the cheapest arrangement available: + +- A **managed volume** is not part of a box's metered resources. Mounting one adds nothing to the box's hourly price. +- A **stopped box** is billed for its disk for as long as it exists. + +So when you want to keep results but not keep paying, write them to a volume and let the box go. See [Volumes](/cloud/volumes). + +## Next steps + + + + Rates, what the standard sizes cost, and how the hours are counted. + + diff --git a/cloud/data-across-boxes.mdx b/cloud/data-across-boxes.mdx new file mode 100644 index 0000000..afd2b41 --- /dev/null +++ b/cloud/data-across-boxes.mdx @@ -0,0 +1,185 @@ +--- +title: "Keep data when the box is gone" +sidebarTitle: "Data across boxes" +description: "Write to a volume from one box, delete that box, and read the same data back from a new one — the reason managed volumes exist." +--- + +A box’s own disk dies with the box. A volume does not. This walks the proof end to end: write from one box, delete it, then read the same bytes from a box that did not exist when they were written. + +## Prerequisites + +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). + +Every example reads both values from the environment, so nothing hard-codes a credential. + +The property that makes a volume worth using: write through the mount in one box, destroy that box, mount the same volume in a different box, and the data reads back. The volume is backed by managed storage, not by the box. + + + +```python Python +import asyncio +import os +import time + +from boxlite import ( + ApiKeyCredential, + Boxlite, + BoxliteRestOptions, + BoxOptions, +) + +# A name is easier to carry between processes than an id. +VOLUME = os.environ.get("BOXLITE_VOLUME", "") +IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" + +async def run_in_fresh_box(rt, name, script): + """Create a box with the volume mounted, run one shell script, then remove the box.""" + box = await rt.create( + BoxOptions(image=IMAGE, volumes=[(VOLUME, "/data")]), + name=name, + ) + try: + await box.start() + execution = await box.exec("sh", args=["-c", script]) + output = "" + async for line in execution.stdout(): + output += line + result = await execution.wait() + return result.exit_code, output + finally: + # The box is gone after this line; the volume is not. + await rt.remove(box.id, force=True) + +async def main(): + rt = Boxlite.rest(BoxliteRestOptions( + url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), + credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), + )) + stamp = int(time.time()) + + try: + # Box A writes, then is destroyed. + code, _ = await run_in_fresh_box( + rt, + f"volume-writer-{stamp}", + "echo 'produced by box A' > /data/handoff.txt", + ) + if code != 0: + print(f"box A write failed with exit code {code}") + return + + # Box B is a different box on the same volume. + code, output = await run_in_fresh_box( + rt, + f"volume-reader-{stamp}", + "cat /data/handoff.txt", + ) + print(f"box B exit code: {code}") + print(f"box B read back: {output}") + except Exception as exc: + print(f"handoff failed: {exc}") + +asyncio.run(main()) +``` + +```typescript Node.js +import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; + +// A name is easier to carry between processes than an id. +const VOLUME = process.env.BOXLITE_VOLUME ?? ""; +const IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"; + +// Create a box with the volume mounted, run one shell script, then remove the box. +async function runInFreshBox(rt, name: string, script: string): Promise<[number, string]> { + const box = await rt.create({ image: IMAGE, volumes: [[VOLUME, "/data"]] }, name); + try { + await box.start(); + const execution = await box.exec("sh", ["-c", script]); + const stdout = await execution.stdout(); + let output = ""; + while (true) { + const line = await stdout.next(); + if (line === null) break; + output += line; + } + const result = await execution.wait(); + return [result.exitCode, output]; + } finally { + // The box is gone after this line; the volume is not. + await rt.remove(box.id, true); + } +} + +async function main(): Promise { + const rt = JsBoxlite.rest( + new BoxliteRestOptions({ + url: process.env.BOXLITE_REST_URL ?? "https://app.boxlite.ai/api", + credential: new ApiKeyCredential(process.env.BOXLITE_API_KEY!), + }), + ); + const stamp = Math.floor(Date.now() / 1000); + + try { + // Box A writes, then is destroyed. + const [writeCode] = await runInFreshBox( + rt, + `volume-writer-${stamp}`, + "echo 'produced by box A' > /data/handoff.txt", + ); + if (writeCode !== 0) { + console.error(`box A write failed with exit code ${writeCode}`); + return; + } + + // Box B is a different box on the same volume. + const [readCode, output] = await runInFreshBox(rt, `volume-reader-${stamp}`, "cat /data/handoff.txt"); + console.log(`box B exit code: ${readCode}`); + console.log(`box B read back: ${output}`); + } catch (err) { + console.error(`handoff failed: ${err instanceof Error ? err.message : err}`); + } finally { + rt.close(); + } +} + +main(); +``` + +```bash REST +# Two boxes, one volume: the first writes, the second reads after the first is gone. +BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://app.boxlite.ai/api}" +VOLUME="${BOXLITE_VOLUME:-}" +IMAGE="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" +AUTH=(-H "Authorization: Bearer ${BOXLITE_API_KEY}" -H 'Content-Type: application/json') + +make_box() { + curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes" "${AUTH[@]}" \ + -d "{\"image\":\"${IMAGE}\",\"volumes\":[{\"managed_volume\":\"${VOLUME}\",\"guest_path\":\"/data\"}]}" \ + | jq -r .id +} + +# Box A writes, then is destroyed. +A=$(make_box) +curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes/${A}/exec" "${AUTH[@]}" \ + -d '{"command":"sh","args":["-c","echo '"'"'produced by box A'"'"' > /data/handoff.txt"]}' > /dev/null +curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/boxes/${A}?force=true" "${AUTH[@]}" + +# Box B is a different box on the same volume. +B=$(make_box) +curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes/${B}/exec" "${AUTH[@]}" \ + -d '{"command":"cat","args":["/data/handoff.txt"]}' +curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/boxes/${B}?force=true" "${AUTH[@]}" +``` + + + +Only what you write **under the mount path** survives. A file written to the box's own filesystem outside `/data` goes away with the box. + +## Next steps + + + + What a volume is, the full parameter reference, and how Cloud differs from open source. + + diff --git a/cloud/index.mdx b/cloud/index.mdx index 199119d..d74ee38 100644 --- a/cloud/index.mdx +++ b/cloud/index.mdx @@ -4,112 +4,42 @@ sidebarTitle: "What is Cloud" description: "A hosted agent runtime built on BoxLite — the same SDK and the same core API, reached with a URL and an API key, with managed storage and a managed box lifecycle." --- -**BoxLite Cloud is a hosted agent runtime.** It runs microVM boxes on BoxLite's resource pool, so your machines need no hardware virtualization — your code needs a URL and an API key. It is built for people building agents: compute, persistent storage, and a managed box lifecycle, shaped around what an agent needs to keep working. +**BoxLite Cloud is a hosted agent runtime.** It runs microVM boxes on BoxLite's resource pool, so your machines need no hardware virtualization — your code needs a URL and an API key. -## Two shapes, one runtime +Cloud is not a separate client library. You install the same package as open-source BoxLite and swap the runtime handle to `Boxlite.rest(...)`. [Quickstart](/cloud/quickstart) has the whole first script. -A box is more than a place to throw code at. Cloud serves both of the shapes agent builders reach for, and you choose per box. +## Two shapes, one runtime | Shape | What it looks like | What Cloud contributes | |---|---|---| | **Disposable container** | Create a box, run untrusted or model-generated code in it, remove it | Hardware-level isolation without a virtualization host of your own | -| **A home for an agent** | One box that keeps its filesystem, its installed packages, and its working state across sessions | Managed storage that outlives the box, plus a managed stop-and-wake lifecycle | +| **A home for an agent** | One box that keeps its filesystem, its packages, and its working state across sessions | Managed storage that outlives the box, plus a managed stop-and-wake lifecycle | The second shape is what a stateful microVM makes possible: an agent installs its tools once, writes files, stops, and picks the work back up later instead of rebuilding its world on every run. -## The same SDK, a different runtime handle - -Cloud is not a separate client library. You install the same package as open-source BoxLite and swap the runtime handle from a local one to `Boxlite.rest(...)`. - -```bash -pip install boxlite -``` - -```python hello_cloud.py -import asyncio -import os -import time - -from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions - - -async def main() -> None: - api_key = os.environ.get("BOXLITE_API_KEY") # — create one in the console - if not api_key: - print("Set BOXLITE_API_KEY before running this script.") - return - - # Synchronous construction; every runtime method below is awaited. - rt = Boxlite.rest( - BoxliteRestOptions( - url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), - credential=ApiKeyCredential(api_key), - ) - ) - - box = None - try: - box = await rt.create( - BoxOptions(image="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"), - name=f"hello-cloud-{int(time.time())}", - ) - await box.start() - - execution = await box.exec("echo", args=["Hello from BoxLite Cloud"]) - output = "" - async for line in execution.stdout(): - output += line - result = await execution.wait() - - print(f"Exit code: {result.exit_code}") - print(output) - except Exception as exc: - print(f"Cloud call failed: {exc!r}") - finally: - if box is not None: - await rt.remove(box.id, force=True) - - -asyncio.run(main()) -``` - -The core API is the same one documented across this site — `create`, `start`, `exec`, `remove`, boxes, volumes. The call shapes differ in a handful of specific places, and the differences are enumerated on one page: [Cloud vs open source](/cloud/vs-opensource). Read it before you port existing code. - -Node, the CLI, and raw REST are covered in the [Cloud quickstart](/cloud/quickstart). - -## What lets an agent stay up - -An agent that lives somewhere needs its state to survive, and it needs someone to run the lights. Three capabilities carry that: - -- **Storage that outlives the box.** A box loses everything on its disk when it is destroyed. A managed volume does not — mount it into a box to read and write, and another box can mount it later. See [Volumes](/cloud/volumes). -- **A managed lifecycle.** When you create a box in the console you set **Stop when idle**, **Wake on access**, and **Delete after stopping**. A stopped box wakes on SDK exec, file operations, or a terminal attach, so the agent's home comes back on demand instead of burning compute while nothing is asking it to work. See [Boxes](/cloud/boxes). -- **Concurrency you do not capacity-plan.** Your plan sets how many boxes run at the same time. Raising it is a plan change, not a hardware purchase. See [Plans, wallet, and usage](/cloud/billing). - -One detail matters when you design around this: idle means no SDK, terminal, or preview traffic. Work running inside the box does not count, so a long job can be stopped mid-run. [Boxes](/cloud/boxes) explains how to set the idle switches for long-running work. - -## What you pay for - -Usage is metered. A subscription plan includes a quota, the quota is consumed first, and a wallet balance funds anything beyond it. Plan tiers, quota amounts, concurrency limits, and per-box resource ceilings all live on [Plans, wallet, and usage](/cloud/billing). + +**Idle means no SDK, terminal, or preview traffic.** Work running *inside* the box does not count, so a long unattended job can be stopped mid-run. See [Lifecycle](/cloud/box-lifecycle#stop-when-idle). + ## Start here - + Create a key, install the SDK, and run your first command on Cloud. - Every difference between the hosted runtime and self-hosted BoxLite, in one table. + Every difference between the hosted runtime and self-hosted BoxLite, in one table — plus how to port existing code. Create, scope, expire, and rotate the `blk_live_...` keys your code authenticates with. - - Images, sizes, and the idle-stop, wake-on-access, and delete-after-stopping switches. - - - Managed storage that outlives a box, and how to mount it. - - - How metering works, what each plan includes, and the per-box resource ceilings. - + +## The rest of this tab + +| Section | What it covers | +|---|---| +| [Boxes](/cloud/boxes) | Images, sizes, the idle-stop and wake-on-access lifecycle, and the full cycle from code | +| [Volumes](/cloud/volumes) | Managed storage that outlives a box, and how to mount it by name | +| [Network](/cloud/network) | Tunnels into a box, preview URLs, port forwarding, and the egress allowlist | +| [Pricing](/cloud/pricing) | The three metered rates, what a box costs, plans, and the wallet | diff --git a/cloud/mount-a-volume.mdx b/cloud/mount-a-volume.mdx new file mode 100644 index 0000000..0b83ff4 --- /dev/null +++ b/cloud/mount-a-volume.mdx @@ -0,0 +1,220 @@ +--- +title: "Create a volume and mount it into a box" +sidebarTitle: "Mount a volume" +description: "Create a managed volume, mount it into a box at a path you choose, and write and read through the mount." +--- + +A managed volume is storage the platform keeps for you, independent of any box. This is the shortest complete path: create one, mount it, write through it, read it back. + +## Prerequisites + +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). + +Every example reads both values from the environment, so nothing hard-codes a credential. + +Create a volume, mount it into a box at `/data`, write a file through the mount, and read it back. This runs as written once the two environment variables are set. + + + +```python Python +# cloud_volume.py — create a named volume, mount it, write and read through it +# Run: python cloud_volume.py +import asyncio +import os +import time + +from boxlite import ( + ApiKeyCredential, + Boxlite, + BoxOptions, + BoxliteRestOptions, +) + +IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" + +api_key = os.environ.get("BOXLITE_API_KEY") +if not api_key: + raise SystemExit("Set BOXLITE_API_KEY to your blk_live_... key before running this.") + + +async def main() -> None: + rt = Boxlite.rest( + BoxliteRestOptions( + url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), + credential=ApiKeyCredential(api_key), + ) + ) + + volume = None + box = None + try: + # rt.volumes is a property. create() takes an optional name that you can + # mount by later, instead of carrying the id around. + volume = await rt.volumes.create(f"demo-{int(time.time())}") + print(f"Created volume {volume.name} (id {volume.id})") + + box = await rt.create( + BoxOptions( + image=IMAGE, + # (managed volume name or id, mount path inside the box) + volumes=[(volume.name, "/data")], + ), + name=f"volume-demo-{int(time.time())}", + ) + await box.start() + + # Write through the mount, not to the box's own disk. + write = await box.exec( + "sh", + args=["-c", "echo 'subtitle model v3' > /data/notes.txt"], + ) + write_result = await write.wait() + if write_result.exit_code != 0: + print(f"write failed with exit code {write_result.exit_code}") + return + + read = await box.exec("cat", args=["/data/notes.txt"]) + content = "" + async for line in read.stdout(): + content += line + read_result = await read.wait() + + print(f"Exit code: {read_result.exit_code}") + print(content) + except Exception as exc: + # Auth failures, creation failures, and local runtimes without a volume + # backend all surface here. + print(f"volume run failed: {exc!r}") + finally: + # Teardown in finally, so a failure above cannot leave a box billing. + if box is not None: + await rt.remove(box.id, force=True) + if volume is not None: + await rt.volumes.remove(volume.id) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +```typescript Node.js +// cloudVolume.ts — create a named volume, mount it, write and read through it +// Run: node cloudVolume.ts +import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; + +const IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"; + +const apiKey = process.env.BOXLITE_API_KEY; +if (!apiKey) { + throw new Error("Set BOXLITE_API_KEY to your blk_live_... key before running this."); +} + +async function main(): Promise { + const rt = JsBoxlite.rest( + new BoxliteRestOptions({ + url: process.env.BOXLITE_REST_URL ?? "https://app.boxlite.ai/api", + credential: new ApiKeyCredential(apiKey), + }), + ); + + const stamp = Math.floor(Date.now() / 1000); + let volume = null; + let box = null; + try { + // rt.volumes is a property. create() takes an optional name you can mount by. + volume = await rt.volumes.create(`demo-${stamp}`); + console.log(`Created volume ${volume.name} (id ${volume.id})`); + + box = await rt.create( + { + image: IMAGE, + // [managed volume name or id, mount path inside the box] + volumes: [[volume.name, "/data"]], + }, + `volume-demo-${stamp}`, + ); + await box.start(); + + // Write through the mount, not to the box's own disk. + const write = await (await box.exec("sh", ["-c", "echo 'subtitle model v3' > /data/notes.txt"])).wait(); + if (write.exitCode !== 0) { + console.error(`write failed with exit code ${write.exitCode}`); + return; + } + + const read = await box.exec("cat", ["/data/notes.txt"]); + const stdout = await read.stdout(); + let content = ""; + while (true) { + const line = await stdout.next(); + if (line === null) break; + content += line; + } + const result = await read.wait(); + + console.log(`Exit code: ${result.exitCode}`); + console.log(content); + } catch (err) { + // Auth failures, creation failures, and local runtimes without a volume + // backend all surface here. + console.error(`volume run failed: ${err instanceof Error ? err.message : err}`); + } finally { + // Teardown in finally, so a failure above cannot leave a box billing. + if (box !== null) await rt.remove(box.id, true); + if (volume !== null) await rt.volumes.remove(volume.id); + rt.close(); + } +} + +main(); +``` + +```bash REST +# Requires BOXLITE_API_KEY. Create a key in the console: /cloud/api-keys +BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://app.boxlite.ai/api}" +IMAGE="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" +NAME="demo-$(date +%s)" + +# 1. Create a named volume. An empty body creates an unnamed one, whose name +# the server sets to the id. +VOLUME=$(curl -fsS -X POST "${BOXLITE_REST_URL}/v1/volumes" \ + -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"${NAME}\"}") +echo "${VOLUME}" | jq '{id, name, state}' + +# 2. Mount it by name. The wire field is managed_volume — it takes a name or an id. +curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes" \ + -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d "{\"image\":\"${IMAGE}\",\"volumes\":[{\"managed_volume\":\"${NAME}\",\"guest_path\":\"/data\"}]}" \ + | jq '{id, name}' + +# 3. Clean up when you are done. +curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/volumes/$(echo "${VOLUME}" | jq -r .id)" \ + -H "Authorization: Bearer ${BOXLITE_API_KEY}" +``` + + + +Two things in that script are worth pausing on, and the rest of this page builds on them: the volume carries a **name you chose**, and the box mounts it by that name. + +## Create a volume in the console + +The console is the other way to create a volume, and the one to use when you want to see what you own. + +1. Open **Volumes** in the console and click **New Volume**. +2. Fill in **Name** — the only field. Pick something you will recognize later, such as `subtitle-models`. +3. Create it, then give it a few seconds to become ready before you mount it. + +The volume now exists independently of any box. You can mount it into a box, destroy that box, and mount it into a different one later. `rt.volumes.list()` and the **Volumes** page report the same set of volumes, and a name set in either place mounts the same way. + + +## Next steps + + + + What a volume is, the full parameter reference, and how Cloud differs from open source. + + diff --git a/cloud/network-policy.mdx b/cloud/network-policy.mdx index e48b8ce..44ef488 100644 --- a/cloud/network-policy.mdx +++ b/cloud/network-policy.mdx @@ -252,7 +252,7 @@ One more thing to know about a box you did not configure yourself. A box created | A signed URL that worked a minute ago now fails | Its lifetime elapsed — the default is 60 seconds when you omit `expiresInSeconds` — or the token was revoked through the expire route | Request a new signed URL, passing an `expiresInSeconds` that matches how long a person actually needs | | A signed URL fails immediately after you issue it | The token was already revoked, or the URL was truncated in transit through a chat client or ticket field | Issue a fresh signed URL and paste the whole `url` value, including its token | | The preview URL loads but nothing answers, or the connection is refused | The service inside the box is bound to `127.0.0.1`, so traffic arriving on the box's network interface cannot reach it | Bind `0.0.0.0` — `python3 -m http.server 8080 --bind 0.0.0.0`, Flask `app.run(host="0.0.0.0")`, uvicorn `--host 0.0.0.0`. Background: [Network](/cloud/network) | -| A preview URL that worked stops responding after a quiet period | The box was stopped for being idle. Preview traffic keeps a running box alive, and does not wake a stopped one | Start the box again, then re-check the URL. Raise or disable the idle timeout for a box that must stay reachable — see [Stop when idle](/cloud/boxes#stop-when-idle) | +| A preview URL that worked stops responding after a quiet period | The box was stopped for being idle. Preview traffic keeps a running box alive, and does not wake a stopped one | Start the box again, then re-check the URL. Raise or disable the idle timeout for a box that must stay reachable — see [Stop when idle](/cloud/box-lifecycle#stop-when-idle) | | The preview URL answers for one port and 404s for another | A preview URL addresses a single port. The second port needs its own | Fetch `GET /api/box/{boxIdOrName}/ports/{port}/preview-url` again with the other port number | | An outbound request in the box connects to `0.0.0.0` and fails there | The host is not on the box's `allow_net` list, and a blocked host is sinkholed rather than refused | Add the host to `allow_net` when the box legitimately needs it. Verify by reading the resolved address, not the exit code — see [Network access](/manage-sandbox/network-access) | | `nslookup` of a blocked host exits `0`, so a script treats it as reachable | The lookup succeeded; it just answered `0.0.0.0` | Test the resolved address in your script, for example by checking whether the output contains `0.0.0.0` | diff --git a/cloud/network.mdx b/cloud/network.mdx index c2b1cd8..d42d0ae 100644 --- a/cloud/network.mdx +++ b/cloud/network.mdx @@ -1,461 +1,44 @@ --- -title: "Reach a service running inside a box" +title: "Network" sidebarTitle: "Network" -description: "Open a tunnel to a port inside a Cloud box to reach an HTTP server, a WebSocket endpoint, or any TCP service — and understand the box's outbound boundary." +description: "Reach a service running inside a Cloud box, and control what that box is allowed to reach on the way out." --- -A box that runs a web app, a dev server, or an SSH daemon is only useful once something can reach it. On BoxLite Cloud you ask a box for a tunnel to one of its ports, and that tunnel gives you a public URL and a byte stream. What the box itself is allowed to reach on the way out is a separate control, covered at the end of this page. +A box that runs a web app, a dev server, or an SSH daemon is only useful once something can reach it. On BoxLite Cloud you ask a box for a **tunnel** to one of its ports, and that tunnel gives you a public URL and a byte stream. What the box itself may reach on the way out is a separate control. -## Prerequisites +Two rules hold across everything here: -- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). -- `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). -- A box you can start, and a service inside it that listens on a TCP port. +- **Your service must bind `0.0.0.0`.** A tunnel arrives on the box's network interface, so a server on `127.0.0.1` inside the box is unreachable. +- **A tunnel carries exactly one connection.** Ask for a fresh one per request, per retry, and per concurrent client. -## Inbound: reach a service inside the box - -### How a tunnel reaches your service - -You do not open a port on the platform. You ask one specific box for a tunnel to one specific port inside it, and `await box.network.tunnel(port)` prepares it. - -Behind that single call: the SDK asks the Cloud API to prepare the tunnel (`POST /v1/boxes/{id}/network/tunnel` if you are driving the REST API yourself), opens a TLS connection to the public proxy, and issues an HTTP `CONNECT`. The proxy connects on to the runner hosting your box, and the runner connects to the guest port. From there the path is your service's socket: the tunnel moves bytes and nothing along it interprets your protocol. - -Two consequences shape everything below. - -- **Your service must bind `0.0.0.0`.** The tunnel terminates on the box's network interface, so a server bound to `127.0.0.1` inside the box is unreachable — the same reason port forwarding needs `0.0.0.0` on a box you run yourself, explained in [Network access](/manage-sandbox/network-access). -- **A prepared tunnel carries exactly one connection.** See [A tunnel is one-shot](#a-tunnel-is-one-shot). - -### Serve HTTP from a box and get its URL - -This script creates a box, starts an HTTP server inside it, prints the tunnel's public URL, makes a request over the tunnel, and removes the box in `finally`. - -```python -# cloud_tunnel_http.py — serve HTTP from a Cloud box and reach it through a tunnel -# Run: python cloud_tunnel_http.py -import asyncio -import os -import time - -from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions - -GUEST_PORT = 18080 -IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" - - -async def http_get(tunnel, path: str = "/") -> bytes: - """Send one HTTP request over one tunnel. connect() consumes the tunnel.""" - connection = await tunnel.connect() - try: - request = ( - f"GET {path} HTTP/1.1\r\n" - "Host: localhost\r\n" - "Connection: close\r\n\r\n" - ).encode() - await connection.write(request) - - response = bytearray() - while True: - chunk = await connection.read(64 * 1024) - if not chunk: # an empty read means the far side closed the stream - break - response.extend(chunk) - return bytes(response) - finally: - await connection.close() - - -async def main() -> None: - # export BOXLITE_API_KEY= before running - rt = Boxlite.rest(BoxliteRestOptions( - url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), - credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), - )) - - box_id = None - try: - box = await rt.create( - BoxOptions(image=IMAGE), - name=f"tunnel-http-{int(time.time())}", - ) - box_id = box.id - await box.start() - - # Bind 0.0.0.0: the tunnel arrives on the box's network interface, - # so a server on 127.0.0.1 inside the box cannot be reached. - launch = await box.exec("sh", args=[ - "-c", - f"nohup python3 -m http.server {GUEST_PORT} --bind 0.0.0.0 " - "> /tmp/server.log 2>&1 & echo launched", - ]) - await launch.wait() - - # uri() is synchronous and does not consume the tunnel. - tunnel = await box.network.tunnel(GUEST_PORT) - print(f"tunnel URL: {tunnel.uri()}") - - # The server needs a moment. Each attempt needs its own tunnel. - response = b"" - for attempt in range(20): - try: - response = await http_get(tunnel) - if response.startswith(b"HTTP/1."): - break - except Exception as exc: - print(f"attempt {attempt + 1}: {type(exc).__name__}: {exc}") - await asyncio.sleep(1) - tunnel = await box.network.tunnel(GUEST_PORT) # the previous one is spent - - if response.startswith(b"HTTP/1."): - print(response.split(b"\r\n", 1)[0].decode()) - else: - log = await box.exec("cat", args=["/tmp/server.log"]) - text = "" - async for line in log.stdout(): - text += line - await log.wait() - print(f"no HTTP response. server log:\n{text}") - except Exception as exc: - print(f"tunnel demo failed: {type(exc).__name__}: {exc}") - finally: - # Teardown on the runtime, in finally, so a failure above cannot leave a box running. - if box_id: - try: - await rt.remove(box_id, force=True) - except Exception as exc: - print(f"remove failed: {exc}") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -Expected output: - -```text -tunnel URL: https://... -HTTP/1.0 200 OK -``` - -`uri()` returns the public URL of a tunnel served remotely, which is what a Cloud tunnel is. On a local runtime the same call returns `None`, because a local tunnel is already a live connection with no address to publish. Reading `uri()` leaves the tunnel usable — only `connect()` and `forward()` spend it. - -The snippet starts the server with `python3`. If the server log says `python3: not found`, install an interpreter into the box first, or start a service the image already ships. - -### A tunnel is one-shot - - -**`connect()` and `forward()` each consume the tunnel.** Calling either one a second time on the same `BoxTunnel` raises a BoxLite error: `tunnel connection has already been consumed`. - -Every connection needs a fresh `await box.network.tunnel(port)`. Ask for one per request, per retry, and per concurrent client — as the loop above does — and never cache a `BoxTunnel` for reuse. Caching the *port number* is fine; caching the tunnel is the bug. - - -Preparing a tunnel is cheap and boxes accept several at once, so this is a shape to lean into rather than work around: many concurrent clients on the same guest port each get their own tunnel, and different guest ports on one box can be tunnelled at the same time. - -### Forward a box port to a local port - -`forward()` publishes the tunnel on an address on your machine, so any TCP client — `curl`, a browser, a database driver, an existing library that only knows how to dial a socket — can reach the service without knowing about BoxLite. - -```python -# cloud_tunnel_forward.py — publish a box port on a local port -# Run: python cloud_tunnel_forward.py -import asyncio -import os -import time -import urllib.request - -from boxlite import ( - ApiKeyCredential, - Boxlite, - BoxliteRestOptions, - BoxOptions, - SocketAddress, -) - -GUEST_PORT = 18080 -IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" - - -async def main() -> None: - # export BOXLITE_API_KEY= before running - rt = Boxlite.rest(BoxliteRestOptions( - url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), - credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), - )) - - box_id = None - forwarder = None - try: - box = await rt.create( - BoxOptions(image=IMAGE), - name=f"tunnel-forward-{int(time.time())}", - ) - box_id = box.id - await box.start() - - launch = await box.exec("sh", args=[ - "-c", - f"nohup python3 -m http.server {GUEST_PORT} --bind 0.0.0.0 " - "> /tmp/server.log 2>&1 & echo launched", - ]) - await launch.wait() - await asyncio.sleep(2) # give the server time to bind - - # host must be a numeric IP; port=0 asks the OS for a free port. - listener = SocketAddress.tcp(host="127.0.0.1", port=0) - - tunnel = await box.network.tunnel(GUEST_PORT) - forwarder = await tunnel.forward(listener) # consumes the tunnel - - local = forwarder.local_addr() # synchronous - print(f"forwarding 127.0.0.1:{local.port} -> box port {GUEST_PORT}") - - # Any TCP client can now dial the local address. urllib is blocking, - # so run it off the event loop. - def fetch() -> int: - url = f"http://127.0.0.1:{local.port}/" - with urllib.request.urlopen(url, timeout=10) as resp: - return resp.status - - print(f"local request status: {await asyncio.to_thread(fetch)}") - except Exception as exc: - print(f"forward demo failed: {type(exc).__name__}: {exc}") - finally: - if forwarder is not None: - try: - await forwarder.close() - except Exception as exc: - print(f"forwarder close failed: {exc}") - if box_id: - try: - await rt.remove(box_id, force=True) - except Exception as exc: - print(f"remove failed: {exc}") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -Three constraints on the listening address: - -- The host must be a **numeric IP**. `SocketAddress.tcp(host="localhost")` raises `ValueError: tunnel listener host must be a numeric IP` — pass `"127.0.0.1"`. -- `port=0` is the default and asks the operating system for a free port. Read the port you actually got from `forwarder.local_addr().port`. -- A Unix socket path must be **absolute**. `SocketAddress.unix("relative.sock")` raises `ValueError: tunnel Unix socket path must be absolute`. - -`await forwarder.wait()` blocks until the forwarder finishes, which is what you await in a long-running process instead of exiting. `await forwarder.close()` shuts it down. The forwarder is built from a tunnel, and `forward()` spends that tunnel, so build each forwarder from its own fresh `await box.network.tunnel(port)`. - -### Read and write raw bytes - -`connect()` hands you the byte stream directly. This is the level you work at for a protocol that is not HTTP — a line protocol, a binary framing, a database wire protocol — or when you want full control over what goes on the wire. - -```python -# cloud_tunnel_bytes.py — talk to a non-HTTP TCP service inside a box -# Run: python cloud_tunnel_bytes.py -import asyncio -import os -import shlex -import time - -from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions - -GUEST_PORT = 18090 -IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" - -# A line protocol: read one newline-terminated line, reply with it upper-cased. -SERVER_CODE = f""" -import socketserver - -class Handler(socketserver.StreamRequestHandler): - def handle(self): - line = self.rfile.readline() - self.wfile.write(line.upper()) - -class Server(socketserver.ThreadingTCPServer): - allow_reuse_address = True - -Server(("0.0.0.0", {GUEST_PORT}), Handler).serve_forever() -""" - - -async def main() -> None: - # export BOXLITE_API_KEY= before running - rt = Boxlite.rest(BoxliteRestOptions( - url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), - credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), - )) - - box_id = None - connection = None - try: - box = await rt.create( - BoxOptions(image=IMAGE), - name=f"tunnel-bytes-{int(time.time())}", - ) - box_id = box.id - await box.start() - - launch = await box.exec("sh", args=[ - "-c", - f"nohup python3 -u -c {shlex.quote(SERVER_CODE)} " - "> /tmp/line-server.log 2>&1 & echo launched", - ]) - await launch.wait() - await asyncio.sleep(2) # give the server time to bind - - tunnel = await box.network.tunnel(GUEST_PORT) - connection = await tunnel.connect() - - await connection.write(b"ping over a raw tunnel\n") - - # Signals that you have finished sending. This server replies to the - # newline, so it does not depend on seeing the half-close -- see Limits. - await connection.shutdown_write() - - reply = bytearray() - while b"\n" not in reply: - chunk = await connection.read(4096) # max_bytes must be non-zero - if not chunk: # empty read: the far side closed the stream - break - reply.extend(chunk) - - print(f"reply: {bytes(reply)!r}") - except Exception as exc: - print(f"byte stream demo failed: {type(exc).__name__}: {exc}") - finally: - if connection is not None: - try: - await connection.close() - except Exception as exc: - print(f"connection close failed: {exc}") - if box_id: - try: - await rt.remove(box_id, force=True) - except Exception as exc: - print(f"remove failed: {exc}") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -`read(max_bytes)` returns up to `max_bytes` bytes and an empty `bytes` object once the far side has closed. `max_bytes` of `0` raises `ValueError: max_bytes must be non-zero`, and reading after `close()` raises a BoxLite error carrying `connection is closed`. - -A synchronous surface exists as well: `SyncBox.network` and `SyncBox.tunnel(port)` return the `SyncNetworkHandle` and `SyncTunnelForwarder` equivalents of the classes documented here. - -### Parameters and returns - -#### `box.network.tunnel(port)` - -Async. Prepares one tunnel to one port inside one box. - -| Parameter | Type | Required | Description | -|---|---|---|---| -| `port` | `int` | Required | The port your service listens on **inside** the box. `0` raises `ValueError: tunnel port must be non-zero` | - -Returns a `BoxTunnel`. `await box.tunnel(port)` is an equivalent shorthand on a `SimpleBox`. - -#### `BoxTunnel` - -| Member | Async | Returns | Description | -|---|---|---|---| -| `uri()` | No | `str \| None` | Public URL of a remotely served tunnel, or `None` for a local one. Does not consume the tunnel | -| `connect()` | Yes | `BoxConnection` | **Consumes the tunnel** and returns its bidirectional byte stream | -| `forward(listen)` | Yes | `TunnelForwarder` | **Consumes the tunnel**, listens on `listen`, and forwards traffic into the box. `listen` is a `SocketAddress` | - -#### `SocketAddress` - -Two class methods build the address `forward()` listens on. - -| Constructor | Signature | Constraint | -|---|---|---| -| `SocketAddress.tcp` | `SocketAddress.tcp(host="127.0.0.1", port=0)` | `host` must be a numeric IP, otherwise `ValueError: tunnel listener host must be a numeric IP`. `port=0` lets the system assign one | -| `SocketAddress.unix` | `SocketAddress.unix(path)` | `path` must be absolute, otherwise `ValueError: tunnel Unix socket path must be absolute` | - -Read-only attributes: - -| Attribute | Type | TCP address | Unix address | -|---|---|---|---| -| `kind` | `str` | `"tcp"` | `"unix"` | -| `host` | `str \| None` | The IP | `None` | -| `port` | `int \| None` | The port | `None` | -| `path` | `str \| None` | `None` | The socket path | - -#### `TunnelForwarder` - -| Member | Async | Returns | Description | -|---|---|---|---| -| `local_addr()` | No | `SocketAddress` | The address actually bound — read this when you passed `port=0` | -| `wait()` | Yes | — | Blocks until the forwarder finishes | -| `close()` | Yes | — | Shuts the forwarder down | - -#### `BoxConnection` - -| Member | Async | Returns | Description | -|---|---|---|---| -| `read(max_bytes)` | Yes | `bytes` | Up to `max_bytes` bytes; empty once the far side closes. `max_bytes` of `0` raises `ValueError: max_bytes must be non-zero`; reading a closed connection raises with `connection is closed` | -| `write(data)` | Yes | `int` | Writes all of `data` (`bytes`) and returns the number of bytes written | -| `shutdown_write()` | Yes | — | Half-closes the write direction. Read [Limits](#limits) before you depend on it | -| `close()` | Yes | — | Closes both directions | - -### What a tunnel carries - -These behaviours are verified end to end against BoxLite Cloud, so you can build on them: - -- **HTTP requests and responses**, `GET` and `POST`. -- **WebSocket** — the upgrade handshake and frames in both directions. -- **Several guest ports on one box**, tunnelled at the same time. -- **Concurrent clients** against the same guest port, each on its own tunnel. -- **Responses larger than 2 MiB** through a single connection, byte-for-byte intact. -- **Slow readers** — a client that drains the stream slowly does not lose data. -- **Client cancellation** — abandoning your side ends that stream without disturbing the box or its other streams. -- **Service restart** — restart the server inside the box, open a fresh tunnel, and traffic flows again. -- **Arbitrary TCP**, including a real SSH session to an `sshd` listening on port 2222. The path is not HTTP-specific. - -One isolation property is worth stating on its own: **two boxes serving on the same guest port never receive each other's traffic.** A tunnel is bound to the box that produced it, and that holds while both boxes take traffic concurrently. - -### Limits - -Three properties of this path to design around. - -**A `CONNECT` to a stopped box can be accepted before the stream fails.** A successful connect is not proof that the box is running — the accept can land and the stream fail immediately afterwards. Treat your first successful read or write as the readiness signal. Tunnel traffic also does not wake a stopped box: the console is explicit that preview traffic keeps a running box alive but does not wake a stopped one, so start the box yourself and check [Stop when idle](/cloud/boxes#stop-when-idle) if it stops under you. - -**A response can be lost after `shutdown_write()`.** TCP half-close is not carried end to end, so a guest that waits for end-of-input before it replies may never reply, and a reply already in flight can be dropped. Use a protocol that frames its own messages — a newline, a length prefix, `Content-Length` — instead of one that signals "done" with EOF. - -**Direct browser use of a tunnel URL is not covered.** The URL from `uri()` is the address the SDK dials when you call `connect()` or `forward()`. Navigating to it in a browser, and browser authentication for a private box, sit outside the verified path. Drive tunnels from the SDK, and when you want a browser on the service, forward the port to your machine as shown above. - -## Outbound: what the box can reach - -Inbound and outbound are separate controls. The outbound side of a box's network boundary is expressed with `NetworkSpec`, passed as the `network` field of `BoxOptions`: `mode` decides whether the box has a network interface at all, and `allow_net` narrows egress to a list of hosts. - -One detail about that allowlist saves a debugging session. A blocked host is a **DNS sinkhole, not a connection error**: a host that is not on the list resolves to `0.0.0.0`. Your code sees a connection to `0.0.0.0` fail, and a `nslookup` of the blocked host can still exit `0`. So when you check whether a host was blocked, inspect the resolved address rather than the exit code. - -The `NetworkSpec` parameter table, the three useful `mode` and `allow_net` combinations, and the verification recipe live on [Network access](/manage-sandbox/network-access). That page owns them, and this page does not restate them. - -## Troubleshooting - -| Symptom | Cause | Fix | -|---|---|---| -| `uri()` returns `None` | The box came from a local runtime, not from `Boxlite.rest(...)`. A local tunnel has no published address | Construct the runtime with `Boxlite.rest(BoxliteRestOptions(url=..., credential=...))` as in [Quickstart](/cloud/quickstart). The endpoint shape is the tell: a local runtime's endpoint is an integer file descriptor, a Cloud runtime's is a URL | -| A BoxLite error carrying `tunnel connection has already been consumed` | `connect()` or `forward()` already spent that `BoxTunnel` | Call `await box.network.tunnel(port)` again for every connection. Never reuse or cache a tunnel object | -| `RuntimeError: Box not started. Use 'async with SimpleBox(...) as box:' or call 'await box.start()' first.` | The tunnel was requested before the box was running | `await box.start()` first, then request the tunnel | -| `ValueError: tunnel port must be non-zero` | `0` was passed as the guest port | Pass the port your service actually listens on inside the box | -| `ValueError: tunnel listener host must be a numeric IP` | `forward()` got a hostname such as `localhost` | Use `SocketAddress.tcp(host="127.0.0.1", port=0)` | -| `ValueError: tunnel Unix socket path must be absolute` | A relative path was passed to `SocketAddress.unix(...)` | Pass an absolute path, for example `/tmp/boxlite-forward.sock` | -| `ValueError: max_bytes must be non-zero` | `read(0)` | Pass a real buffer size, for example `read(64 * 1024)` | -| The connect succeeds and the stream drops immediately | The box is stopped. A `CONNECT` can be accepted before the stream fails | Confirm the box is running and start it if not. If it keeps stopping under you, it is being stopped for being idle — see [Stop when idle](/cloud/boxes#stop-when-idle) | -| The connection is established but nothing answers | The service inside the box is bound to `127.0.0.1`. A tunnel arrives on the box's network interface, not on its loopback | Bind `0.0.0.0`, for example `python3 -m http.server 18080 --bind 0.0.0.0`, Flask `app.run(host="0.0.0.0")`, uvicorn `--host 0.0.0.0`. Background: [Network access](/manage-sandbox/network-access) | -| No reply arrives after `shutdown_write()` | The guest is waiting for end-of-input, and TCP half-close is not carried end to end | Frame the protocol so the guest knows a message is complete without EOF — a newline, a length prefix, or `Content-Length` | -| An `exec` that starts the server returns exit code `0` but nothing listens | The command was backgrounded, so its exit code says nothing about the server | Read the server's log file back out of the box, as the first example does with `/tmp/server.log` | - -## Next steps +## In this section - - Images, sizes, and the lifecycle controls that decide whether your service is still listening. + + The concept, the one-shot rule, what a tunnel is verified to carry, and the full parameter reference. **Start here.** - - The five-call Cloud lifecycle, from API key to teardown. + + Start an HTTP server inside a box, get its public URL, and make a request over it. - - The `NetworkSpec` and `ports` parameter tables, and the egress allowlist in full. + + Publish a box port on your own machine so `curl`, a browser, or a database driver can dial it. + + + Take the byte stream directly when you are speaking a protocol of your own. + + + Who can reach a box — public, private, signed URLs — and the egress allowlist that bounds what it reaches. + +## Which page do I want? + +| You want | Page | +|---|---| +| To understand the model before writing code | [How tunnels work](/cloud/tunnels) | +| A public URL for an HTTP service in a box | [Serve HTTP](/cloud/serve-http) | +| A local port any TCP client can dial | [Port forwarding](/cloud/port-forwarding) | +| The raw bidirectional stream | [Raw streams](/cloud/raw-streams) | +| To make a box public, private, or shared by signed URL | [Network policy](/cloud/network-policy#inbound-who-can-reach-a-box) | +| To restrict what the box can call out to | [Network policy](/cloud/network-policy#outbound-what-the-box-can-reach) | +| A parameter table for `tunnel()`, `BoxTunnel`, or `SocketAddress` | [How tunnels work](/cloud/tunnels#parameters-and-returns) | diff --git a/cloud/plans.mdx b/cloud/plans.mdx new file mode 100644 index 0000000..4e3cdd5 --- /dev/null +++ b/cloud/plans.mdx @@ -0,0 +1,86 @@ +--- +title: "Wallet or a plan?" +sidebarTitle: "Choosing a plan" +description: "Two ways to fund usage on BoxLite Cloud, the usage level at which a subscription starts costing less than paying from your wallet, and the concurrency limit that is not a money question at all." +--- + +Usage on BoxLite Cloud can be funded two ways: from a prepaid **wallet** alone, or from a **plan** whose included quota covers usage first and whose wallet picks up the rest. Which one costs less is arithmetic, and it turns on a single number — your monthly usage. + +## How funding works + +**Quota covers first, the wallet funds the rest.** A box draws on this cycle's included quota until the quota is gone, and on your prepaid wallet balance from then on. + +An account does not have to carry a plan. With no plan there is no included quota, and every dollar of usage comes straight out of the wallet — pay-as-you-go, in the ordinary sense. + +## The plans + +Each plan buys more usage than it costs, which is where the saving comes from: + +| Plan | Price | Included quota | Concurrency limit | Saving past quota | +|---|---|---|---|---| +| Starter | $19/mo | $30 | 20 boxes | $11/mo | +| Pro | $149/mo | $250 | 100 boxes | $101/mo | +| Max | $499/mo | $900 | 1000 boxes | $401/mo | + +The last column is the quota minus the price. Once your usage passes a plan's quota, that is exactly what the plan saves you against paying full price from the wallet, every month. + +**Enterprise** is negotiated rather than self-serve: custom limits and compliance review, arranged through [sales@boxlite.ai](mailto:sales@boxlite.ai). A negotiated plan carries no public price, and an unlimited quota or concurrency shows as unlimited rather than as a number. + +## Which one costs less + +Look up what the console reports as spent this month, and three thresholds decide it: + +| When your monthly usage passes | Move to | Because | +|---|---|---| +| **$20** | Starter | Starter's $19 buys $30 of usage | +| **$161** | Pro | Pro's flat $149 beats Starter's $150 at this point | +| **$601** | Max | Max's flat $499 beats Pro's $500 at this point | + +The first one generalizes: **once your usage exceeds a plan's price, that plan costs less than paying full price from the wallet.** Below its price you are buying quota you will not use. + +These thresholds assume a typical month. If your usage swings — a heavy month followed by a quiet one — the plan price is charged either way, so size against your *steady* level rather than your peak. + +## Concurrency is a separate decision + +Concurrency caps how many boxes run **at the same time** — not how many you create over a month, and not how much work each one does. It is independent of quota: you can hit the limit with quota to spare, and exhaust quota while running a single box. + +If you are building an agent fleet, size against your fan-out. An orchestrator that runs 40 parallel agents, one box each, needs a plan whose limit clears 40 — Starter's 20 will stall it halfway through the batch. + +So concurrency alone can push you onto a larger plan well before the money says to move. A fleet of 40 short-lived Small boxes might spend $60 a month — Starter territory on cost — while needing Pro's 100-box limit to run at all. + +## Changing plans + +The direction of the change decides when it lands and what you are charged: + +| Change | When it applies | What you are charged now | +|---|---|---| +| **Upgrade** | Immediately, and the new quota is available right away | The prorated difference, today | +| **Downgrade** | At the next cycle roll | Nothing | +| **Cancel** | At the end of the cycle you have paid for | Nothing | +| **Subscribe** (from no plan) | As soon as payment is confirmed | The plan price, through Stripe checkout | + +Two details worth knowing before you act: + +- **A downgrade does not cost you this cycle's quota.** The quota you already paid for stays available until the cycle rolls, along with the current plan's concurrency limit. +- **A queued change can be called off.** While a downgrade or cancellation is scheduled, the console offers to keep your current plan instead, which discards the queued change. + +## Troubleshooting + +| Situation | Why it happens | What to do | +|---|---|---| +| New boxes are refused while your existing boxes keep running fine | You are at your plan's concurrency limit — the cap is on boxes running simultaneously, not on boxes created | Stop or delete a box to free a slot, or move to a plan with a higher limit | +| Cost is showing as wallet-funded rather than quota-covered | This cycle's included quota is consumed, so usage now draws on the prepaid balance | Nothing is broken. Keep the wallet funded, or move to a plan whose quota matches your steady-state usage | +| Your plan shows as having no subscription | The account carries no plan, so there is no included quota and no plan concurrency limit | Pick a plan, or keep running on wallet balance alone if your usage is below $20/mo | +| A plan change you scheduled has not happened | Downgrades and cancellations apply at the cycle roll, not immediately | Check the cycle end date. To act sooner, upgrade instead — upgrades apply immediately | +| Switching away from your current plan is refused | Your plan is negotiated rather than self-serve, so it has no catalog price to switch against | Talk to [sales@boxlite.ai](mailto:sales@boxlite.ai) before changing | + +## Next steps + + + + The hourly rates behind every dollar figure on this page. + + + Top up the wallet, set automatic reload, and read the usage chart. + + diff --git a/cloud/port-forwarding.mdx b/cloud/port-forwarding.mdx new file mode 100644 index 0000000..468a6b0 --- /dev/null +++ b/cloud/port-forwarding.mdx @@ -0,0 +1,117 @@ +--- +title: "Forward a box port to a local port" +sidebarTitle: "Port forwarding" +description: "Publish a port inside a Cloud box on an address on your own machine, so any TCP client can reach it without knowing about BoxLite." +--- + +Some clients cannot be taught to speak through an SDK — `curl`, a browser, a database driver, an existing library that only knows how to dial a socket. `forward()` publishes the tunnel on a local address so all of them just work. + +## Prerequisites + +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). +- A box you can start, and a service inside it that listens on a TCP port. + +Every example reads both values from the environment, so nothing hard-codes a credential. + +`forward()` publishes the tunnel on an address on your machine, so any TCP client — `curl`, a browser, a database driver, an existing library that only knows how to dial a socket — can reach the service without knowing about BoxLite. + +```python +# cloud_tunnel_forward.py — publish a box port on a local port +# Run: python cloud_tunnel_forward.py +import asyncio +import os +import time +import urllib.request + +from boxlite import ( + ApiKeyCredential, + Boxlite, + BoxliteRestOptions, + BoxOptions, + SocketAddress, +) + +GUEST_PORT = 18080 +IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" + + +async def main() -> None: + # export BOXLITE_API_KEY= before running + rt = Boxlite.rest(BoxliteRestOptions( + url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), + credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), + )) + + box_id = None + forwarder = None + try: + box = await rt.create( + BoxOptions(image=IMAGE), + name=f"tunnel-forward-{int(time.time())}", + ) + box_id = box.id + await box.start() + + launch = await box.exec("sh", args=[ + "-c", + f"nohup python3 -m http.server {GUEST_PORT} --bind 0.0.0.0 " + "> /tmp/server.log 2>&1 & echo launched", + ]) + await launch.wait() + await asyncio.sleep(2) # give the server time to bind + + # host must be a numeric IP; port=0 asks the OS for a free port. + listener = SocketAddress.tcp(host="127.0.0.1", port=0) + + tunnel = await box.network.tunnel(GUEST_PORT) + forwarder = await tunnel.forward(listener) # consumes the tunnel + + local = forwarder.local_addr() # synchronous + print(f"forwarding 127.0.0.1:{local.port} -> box port {GUEST_PORT}") + + # Any TCP client can now dial the local address. urllib is blocking, + # so run it off the event loop. + def fetch() -> int: + url = f"http://127.0.0.1:{local.port}/" + with urllib.request.urlopen(url, timeout=10) as resp: + return resp.status + + print(f"local request status: {await asyncio.to_thread(fetch)}") + except Exception as exc: + print(f"forward demo failed: {type(exc).__name__}: {exc}") + finally: + if forwarder is not None: + try: + await forwarder.close() + except Exception as exc: + print(f"forwarder close failed: {exc}") + if box_id: + try: + await rt.remove(box_id, force=True) + except Exception as exc: + print(f"remove failed: {exc}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +Three constraints on the listening address: + +- The host must be a **numeric IP**. `SocketAddress.tcp(host="localhost")` raises `ValueError: tunnel listener host must be a numeric IP` — pass `"127.0.0.1"`. +- `port=0` is the default and asks the operating system for a free port. Read the port you actually got from `forwarder.local_addr().port`. +- A Unix socket path must be **absolute**. `SocketAddress.unix("relative.sock")` raises `ValueError: tunnel Unix socket path must be absolute`. + +`await forwarder.wait()` blocks until the forwarder finishes, which is what you await in a long-running process instead of exiting. `await forwarder.close()` shuts it down. The forwarder is built from a tunnel, and `forward()` spends that tunnel, so build each forwarder from its own fresh `await box.network.tunnel(port)`. + +## Next steps + + + + How tunnels work, the full parameter reference, and the outbound boundary. + + + Control what the box itself is allowed to reach. + + diff --git a/cloud/pricing.mdx b/cloud/pricing.mdx new file mode 100644 index 0000000..b72f5e9 --- /dev/null +++ b/cloud/pricing.mdx @@ -0,0 +1,45 @@ +--- +title: "Pricing" +sidebarTitle: "Pricing" +description: "What BoxLite Cloud charges for: three metered resources billed by the hour, funded by a prepaid wallet or a plan's included quota." +--- + +BoxLite Cloud meters three things, by the hour: + +| Resource | Unit | Rate | +|---|---|---| +| vCPU | per vCPU-hour | **$0.0504** | +| Memory | per GiB-hour | **$0.0144** | +| Disk | per GiB-hour | **$0.00018** | + +Nothing else is metered — no charge for network traffic, API requests, mounted volumes, preview URLs, or boot time. There is no minimum billable duration and no rounding up to the hour. + +Usage is funded by a prepaid **wallet**, or by a **plan** whose included quota is consumed first. + +## In this section + + + + The formula, what the standard sizes come to per hour and per month, and exactly which hours are counted. **Start here.** + + + Why a stopped box keeps charging you, and the two controls that stop it. + + + Plan tiers, and the usage level at which a subscription starts costing less. + + + Top up, set automatic reload, and read what you were actually charged. + + + +## Which page do I want? + +| You are asking | Page | +|---|---| +| What will a Medium box cost me for a month? | [What a box costs](/cloud/box-costs) | +| Why am I still being charged for a box I stopped? | [Cost controls](/cloud/cost-controls) | +| Should I subscribe or keep paying from the wallet? | [Wallet or a plan?](/cloud/plans) | +| How do I stop my wallet hitting zero overnight? | [Managing billing](/cloud/billing#set-automatic-reload) | +| What was I actually charged? | [Managing billing](/cloud/billing#read-the-usage-chart) | +| How large can a single box be? | [Managing billing](/cloud/billing#per-box-resource-ceilings) | diff --git a/cloud/quickstart.mdx b/cloud/quickstart.mdx index 6dcafee..60748e9 100644 --- a/cloud/quickstart.mdx +++ b/cloud/quickstart.mdx @@ -264,7 +264,7 @@ The five calls in that script are the whole Cloud lifecycle. Every language bind 4. **Exec.** `box.exec("echo", args=[...])` launches one process inside the VM and hands you an execution handle. `execution.wait()` blocks until that process exits and returns its exit code. 5. **Remove.** `rt.remove(box.id, force=True)` destroys the box and its disk. Removal is a **runtime** method, not a box method — the box handle you hold is a pointer, and the runtime owns the fleet. -Step 5 is the one to internalize. A box you forget to remove keeps running, and a running box is what BoxLite Cloud charges for — see [Plans, wallet, and usage](/cloud/billing). Putting teardown in a `finally` block, as the script above does, is the difference between an exception costing you a stack trace and an exception costing you a box. +Step 5 is the one to internalize. A box you forget to remove keeps running, and a running box is what BoxLite Cloud charges for — see [What a box costs](/cloud/box-costs). Putting teardown in a `finally` block, as the script above does, is the difference between an exception costing you a stack trace and an exception costing you a box. ## Expected output @@ -284,7 +284,7 @@ The `Exit code` line comes from `execution.wait()` and is the authoritative succ | `401 Unauthorized` on the first call | The key is missing, mistyped, or was deleted in the console. | Confirm the value starts with `blk_live_` and matches a live key in **API Keys**. See [API keys and authentication](/cloud/api-keys#troubleshooting). | | `SystemExit: Set BOXLITE_API_KEY ...`, or the CLI uses your local runtime instead of Cloud | The key never reached the process — a new shell, a different terminal tab, or a service manager that does not inherit your exports. | Re-run the `read -rs` and `export` pair from step 2 in that shell. For anything long-lived, store the credential with `boxlite auth login` instead of exporting it. | | A box from an earlier run is still listed as running | The script exited before teardown — an exception outside `try`, a `Ctrl-C`, or a crash. | Remove it explicitly with `await rt.remove("", force=True)` (CLI: `boxlite rm`), then move teardown into a `finally` block as shown above. | -| A long job stops part-way through with no error from your code | The box was stopped for being idle, and work running *inside* the box does not count as activity. | Adjust **STOP WHEN IDLE** when you create the box in the console — see [Stop when idle](/cloud/boxes#stop-when-idle). | +| A long job stops part-way through with no error from your code | The box was stopped for being idle, and work running *inside* the box does not count as activity. | Adjust **STOP WHEN IDLE** when you create the box in the console — see [Stop when idle](/cloud/box-lifecycle#stop-when-idle). | | `execution.stdout()` finishes without yielding lines | Streamed output is a separate channel from the exit code. Only `execution.wait()` is guaranteed to report the process result. | Treat `result.exit_code` as the success signal and collect output as a bonus. When you need output you can depend on, redirect it to a file in the box and read the file back. | | `404 Not Found` on every request | The base URL is wrong — most often the `/api` suffix is missing. | Set `BOXLITE_REST_URL` to exactly `https://app.boxlite.ai/api`. | diff --git a/cloud/raw-streams.mdx b/cloud/raw-streams.mdx new file mode 100644 index 0000000..2764d66 --- /dev/null +++ b/cloud/raw-streams.mdx @@ -0,0 +1,122 @@ +--- +title: "Read and write raw bytes over a tunnel" +sidebarTitle: "Raw streams" +description: "Drive a tunnel as a bidirectional byte stream when you are speaking a protocol of your own rather than HTTP." +--- + +A tunnel moves bytes and interprets nothing along the way. When your service speaks a protocol of its own, take the connection directly and read and write it yourself. + +## Prerequisites + +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). +- A box you can start, and a service inside it that listens on a TCP port. + +Every example reads both values from the environment, so nothing hard-codes a credential. + +`connect()` hands you the byte stream directly. This is the level you work at for a protocol that is not HTTP — a line protocol, a binary framing, a database wire protocol — or when you want full control over what goes on the wire. + +```python +# cloud_tunnel_bytes.py — talk to a non-HTTP TCP service inside a box +# Run: python cloud_tunnel_bytes.py +import asyncio +import os +import shlex +import time + +from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions + +GUEST_PORT = 18090 +IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" + +# A line protocol: read one newline-terminated line, reply with it upper-cased. +SERVER_CODE = f""" +import socketserver + +class Handler(socketserver.StreamRequestHandler): + def handle(self): + line = self.rfile.readline() + self.wfile.write(line.upper()) + +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + +Server(("0.0.0.0", {GUEST_PORT}), Handler).serve_forever() +""" + + +async def main() -> None: + # export BOXLITE_API_KEY= before running + rt = Boxlite.rest(BoxliteRestOptions( + url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), + credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), + )) + + box_id = None + connection = None + try: + box = await rt.create( + BoxOptions(image=IMAGE), + name=f"tunnel-bytes-{int(time.time())}", + ) + box_id = box.id + await box.start() + + launch = await box.exec("sh", args=[ + "-c", + f"nohup python3 -u -c {shlex.quote(SERVER_CODE)} " + "> /tmp/line-server.log 2>&1 & echo launched", + ]) + await launch.wait() + await asyncio.sleep(2) # give the server time to bind + + tunnel = await box.network.tunnel(GUEST_PORT) + connection = await tunnel.connect() + + await connection.write(b"ping over a raw tunnel\n") + + # Signals that you have finished sending. This server replies to the + # newline, so it does not depend on seeing the half-close -- see Limits. + await connection.shutdown_write() + + reply = bytearray() + while b"\n" not in reply: + chunk = await connection.read(4096) # max_bytes must be non-zero + if not chunk: # empty read: the far side closed the stream + break + reply.extend(chunk) + + print(f"reply: {bytes(reply)!r}") + except Exception as exc: + print(f"byte stream demo failed: {type(exc).__name__}: {exc}") + finally: + if connection is not None: + try: + await connection.close() + except Exception as exc: + print(f"connection close failed: {exc}") + if box_id: + try: + await rt.remove(box_id, force=True) + except Exception as exc: + print(f"remove failed: {exc}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +`read(max_bytes)` returns up to `max_bytes` bytes and an empty `bytes` object once the far side has closed. `max_bytes` of `0` raises `ValueError: max_bytes must be non-zero`, and reading after `close()` raises a BoxLite error carrying `connection is closed`. + +A synchronous surface exists as well: `SyncBox.network` and `SyncBox.tunnel(port)` return the `SyncNetworkHandle` and `SyncTunnelForwarder` equivalents of the classes documented here. + +## Next steps + + + + How tunnels work, the full parameter reference, and the outbound boundary. + + + Control what the box itself is allowed to reach. + + diff --git a/cloud/serve-http.mdx b/cloud/serve-http.mdx new file mode 100644 index 0000000..d543472 --- /dev/null +++ b/cloud/serve-http.mdx @@ -0,0 +1,139 @@ +--- +title: "Serve HTTP from a box and get a public URL" +sidebarTitle: "Preview URLs" +description: "Start an HTTP server inside a Cloud box, open a tunnel to its port, and use the public URL the tunnel gives you." +--- + +A box running a web app, a dev server, or an API is only useful once something outside can reach it. Ask the box for a tunnel to the port your server listens on, and the tunnel hands you a public URL. + +## Prerequisites + +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). +- A box you can start, and a service inside it that listens on a TCP port. + +Every example reads both values from the environment, so nothing hard-codes a credential. + +This script creates a box, starts an HTTP server inside it, prints the tunnel's public URL, makes a request over the tunnel, and removes the box in `finally`. + +```python +# cloud_tunnel_http.py — serve HTTP from a Cloud box and reach it through a tunnel +# Run: python cloud_tunnel_http.py +import asyncio +import os +import time + +from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions + +GUEST_PORT = 18080 +IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" + + +async def http_get(tunnel, path: str = "/") -> bytes: + """Send one HTTP request over one tunnel. connect() consumes the tunnel.""" + connection = await tunnel.connect() + try: + request = ( + f"GET {path} HTTP/1.1\r\n" + "Host: localhost\r\n" + "Connection: close\r\n\r\n" + ).encode() + await connection.write(request) + + response = bytearray() + while True: + chunk = await connection.read(64 * 1024) + if not chunk: # an empty read means the far side closed the stream + break + response.extend(chunk) + return bytes(response) + finally: + await connection.close() + + +async def main() -> None: + # export BOXLITE_API_KEY= before running + rt = Boxlite.rest(BoxliteRestOptions( + url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), + credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), + )) + + box_id = None + try: + box = await rt.create( + BoxOptions(image=IMAGE), + name=f"tunnel-http-{int(time.time())}", + ) + box_id = box.id + await box.start() + + # Bind 0.0.0.0: the tunnel arrives on the box's network interface, + # so a server on 127.0.0.1 inside the box cannot be reached. + launch = await box.exec("sh", args=[ + "-c", + f"nohup python3 -m http.server {GUEST_PORT} --bind 0.0.0.0 " + "> /tmp/server.log 2>&1 & echo launched", + ]) + await launch.wait() + + # uri() is synchronous and does not consume the tunnel. + tunnel = await box.network.tunnel(GUEST_PORT) + print(f"tunnel URL: {tunnel.uri()}") + + # The server needs a moment. Each attempt needs its own tunnel. + response = b"" + for attempt in range(20): + try: + response = await http_get(tunnel) + if response.startswith(b"HTTP/1."): + break + except Exception as exc: + print(f"attempt {attempt + 1}: {type(exc).__name__}: {exc}") + await asyncio.sleep(1) + tunnel = await box.network.tunnel(GUEST_PORT) # the previous one is spent + + if response.startswith(b"HTTP/1."): + print(response.split(b"\r\n", 1)[0].decode()) + else: + log = await box.exec("cat", args=["/tmp/server.log"]) + text = "" + async for line in log.stdout(): + text += line + await log.wait() + print(f"no HTTP response. server log:\n{text}") + except Exception as exc: + print(f"tunnel demo failed: {type(exc).__name__}: {exc}") + finally: + # Teardown on the runtime, in finally, so a failure above cannot leave a box running. + if box_id: + try: + await rt.remove(box_id, force=True) + except Exception as exc: + print(f"remove failed: {exc}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +Expected output: + +```text +tunnel URL: https://... +HTTP/1.0 200 OK +``` + +`uri()` returns the public URL of a tunnel served remotely, which is what a Cloud tunnel is. On a local runtime the same call returns `None`, because a local tunnel is already a live connection with no address to publish. Reading `uri()` leaves the tunnel usable — only `connect()` and `forward()` spend it. + +The snippet starts the server with `python3`. If the server log says `python3: not found`, install an interpreter into the box first, or start a service the image already ships. + +## Next steps + + + + How tunnels work, the full parameter reference, and the outbound boundary. + + + Control what the box itself is allowed to reach. + + diff --git a/cloud/tunnels.mdx b/cloud/tunnels.mdx new file mode 100644 index 0000000..d00201d --- /dev/null +++ b/cloud/tunnels.mdx @@ -0,0 +1,147 @@ +--- +title: "How tunnels work" +sidebarTitle: "Tunnels" +description: "The one API behind every way of reaching into a Cloud box: how a tunnel is established, why each one carries a single connection, what it is verified to carry, and the full parameter reference." +--- + +Every way of reaching a service inside a Cloud box goes through the same object: a tunnel. This page is the concept and the reference. The task pages — [Serve HTTP](/cloud/serve-http), [Port forwarding](/cloud/port-forwarding), [Raw streams](/cloud/raw-streams) — are three ways of consuming what it returns. + +## Prerequisites + +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). + +## How a tunnel reaches your service + +You do not open a port on the platform. You ask one specific box for a tunnel to one specific port inside it, and `await box.network.tunnel(port)` prepares it. + +Behind that single call: the SDK asks the Cloud API to prepare the tunnel (`POST /v1/boxes/{id}/network/tunnel` if you are driving the REST API yourself), opens a TLS connection to the public proxy, and issues an HTTP `CONNECT`. The proxy connects on to the runner hosting your box, and the runner connects to the guest port. From there the path is your service's socket: the tunnel moves bytes and nothing along it interprets your protocol. + +Two consequences shape every use of a tunnel. + +- **Your service must bind `0.0.0.0`.** The tunnel terminates on the box's network interface, so a server bound to `127.0.0.1` inside the box is unreachable — the same reason port forwarding needs `0.0.0.0` on a box you run yourself, explained in [Network access](/manage-sandbox/network-access). +- **A prepared tunnel carries exactly one connection.** See [A tunnel is one-shot](#a-tunnel-is-one-shot). + +## A tunnel is one-shot + + +**`connect()` and `forward()` each consume the tunnel.** Calling either one a second time on the same `BoxTunnel` raises a BoxLite error: `tunnel connection has already been consumed`. + +Every connection needs a fresh `await box.network.tunnel(port)`. Ask for one per request, per retry, and per concurrent client — one per connection — and never cache a `BoxTunnel` for reuse. Caching the *port number* is fine; caching the tunnel is the bug. + + +Preparing a tunnel is cheap and boxes accept several at once, so this is a shape to lean into rather than work around: many concurrent clients on the same guest port each get their own tunnel, and different guest ports on one box can be tunnelled at the same time. + +## Parameters and returns + +### `box.network.tunnel(port)` + +Async. Prepares one tunnel to one port inside one box. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `port` | `int` | Required | The port your service listens on **inside** the box. `0` raises `ValueError: tunnel port must be non-zero` | + +Returns a `BoxTunnel`. `await box.tunnel(port)` is an equivalent shorthand on a `SimpleBox`. + +### `BoxTunnel` + +| Member | Async | Returns | Description | +|---|---|---|---| +| `uri()` | No | `str \| None` | Public URL of a remotely served tunnel, or `None` for a local one. Does not consume the tunnel | +| `connect()` | Yes | `BoxConnection` | **Consumes the tunnel** and returns its bidirectional byte stream | +| `forward(listen)` | Yes | `TunnelForwarder` | **Consumes the tunnel**, listens on `listen`, and forwards traffic into the box. `listen` is a `SocketAddress` | + +### `SocketAddress` + +Two class methods build the address `forward()` listens on. + +| Constructor | Signature | Constraint | +|---|---|---| +| `SocketAddress.tcp` | `SocketAddress.tcp(host="127.0.0.1", port=0)` | `host` must be a numeric IP, otherwise `ValueError: tunnel listener host must be a numeric IP`. `port=0` lets the system assign one | +| `SocketAddress.unix` | `SocketAddress.unix(path)` | `path` must be absolute, otherwise `ValueError: tunnel Unix socket path must be absolute` | + +Read-only attributes: + +| Attribute | Type | TCP address | Unix address | +|---|---|---|---| +| `kind` | `str` | `"tcp"` | `"unix"` | +| `host` | `str \| None` | The IP | `None` | +| `port` | `int \| None` | The port | `None` | +| `path` | `str \| None` | `None` | The socket path | + +### `TunnelForwarder` + +| Member | Async | Returns | Description | +|---|---|---|---| +| `local_addr()` | No | `SocketAddress` | The address actually bound — read this when you passed `port=0` | +| `wait()` | Yes | — | Blocks until the forwarder finishes | +| `close()` | Yes | — | Shuts the forwarder down | + +### `BoxConnection` + +| Member | Async | Returns | Description | +|---|---|---|---| +| `read(max_bytes)` | Yes | `bytes` | Up to `max_bytes` bytes; empty once the far side closes. `max_bytes` of `0` raises `ValueError: max_bytes must be non-zero`; reading a closed connection raises with `connection is closed` | +| `write(data)` | Yes | `int` | Writes all of `data` (`bytes`) and returns the number of bytes written | +| `shutdown_write()` | Yes | — | Half-closes the write direction. Read [Limits](#limits) before you depend on it | +| `close()` | Yes | — | Closes both directions | + +## What a tunnel carries + +These behaviours are verified end to end against BoxLite Cloud, so you can build on them: + +- **HTTP requests and responses**, `GET` and `POST`. +- **WebSocket** — the upgrade handshake and frames in both directions. +- **Several guest ports on one box**, tunnelled at the same time. +- **Concurrent clients** against the same guest port, each on its own tunnel. +- **Responses larger than 2 MiB** through a single connection, byte-for-byte intact. +- **Slow readers** — a client that drains the stream slowly does not lose data. +- **Client cancellation** — abandoning your side ends that stream without disturbing the box or its other streams. +- **Service restart** — restart the server inside the box, open a fresh tunnel, and traffic flows again. +- **Arbitrary TCP**, including a real SSH session to an `sshd` listening on port 2222. The path is not HTTP-specific. + +One isolation property is worth stating on its own: **two boxes serving on the same guest port never receive each other's traffic.** A tunnel is bound to the box that produced it, and that holds while both boxes take traffic concurrently. + +## Limits + +Three properties of this path to design around. + +**A `CONNECT` to a stopped box can be accepted before the stream fails.** A successful connect is not proof that the box is running — the accept can land and the stream fail immediately afterwards. Treat your first successful read or write as the readiness signal. Tunnel traffic also does not wake a stopped box: the console is explicit that preview traffic keeps a running box alive but does not wake a stopped one, so start the box yourself and check [Stop when idle](/cloud/box-lifecycle#stop-when-idle) if it stops under you. + +**A response can be lost after `shutdown_write()`.** TCP half-close is not carried end to end, so a guest that waits for end-of-input before it replies may never reply, and a reply already in flight can be dropped. Use a protocol that frames its own messages — a newline, a length prefix, `Content-Length` — instead of one that signals "done" with EOF. + +**Direct browser use of a tunnel URL is not covered.** The URL from `uri()` is the address the SDK dials when you call `connect()` or `forward()`. Navigating to it in a browser, and browser authentication for a private box, sit outside the verified path. Drive tunnels from the SDK, and when you want a browser on the service, forward the port to your machine with [Port forwarding](/cloud/port-forwarding). + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `uri()` returns `None` | The box came from a local runtime, not from `Boxlite.rest(...)`. A local tunnel has no published address | Construct the runtime with `Boxlite.rest(BoxliteRestOptions(url=..., credential=...))` as in [Quickstart](/cloud/quickstart). The endpoint shape is the tell: a local runtime's endpoint is an integer file descriptor, a Cloud runtime's is a URL | +| A BoxLite error carrying `tunnel connection has already been consumed` | `connect()` or `forward()` already spent that `BoxTunnel` | Call `await box.network.tunnel(port)` again for every connection. Never reuse or cache a tunnel object | +| `RuntimeError: Box not started. Use 'async with SimpleBox(...) as box:' or call 'await box.start()' first.` | The tunnel was requested before the box was running | `await box.start()` first, then request the tunnel | +| `ValueError: tunnel port must be non-zero` | `0` was passed as the guest port | Pass the port your service actually listens on inside the box | +| `ValueError: tunnel listener host must be a numeric IP` | `forward()` got a hostname such as `localhost` | Use `SocketAddress.tcp(host="127.0.0.1", port=0)` | +| `ValueError: tunnel Unix socket path must be absolute` | A relative path was passed to `SocketAddress.unix(...)` | Pass an absolute path, for example `/tmp/boxlite-forward.sock` | +| `ValueError: max_bytes must be non-zero` | `read(0)` | Pass a real buffer size, for example `read(64 * 1024)` | +| The connect succeeds and the stream drops immediately | The box is stopped. A `CONNECT` can be accepted before the stream fails | Confirm the box is running and start it if not. If it keeps stopping under you, it is being stopped for being idle — see [Stop when idle](/cloud/box-lifecycle#stop-when-idle) | +| The connection is established but nothing answers | The service inside the box is bound to `127.0.0.1`. A tunnel arrives on the box's network interface, not on its loopback | Bind `0.0.0.0`, for example `python3 -m http.server 18080 --bind 0.0.0.0`, Flask `app.run(host="0.0.0.0")`, uvicorn `--host 0.0.0.0`. Background: [Network access](/manage-sandbox/network-access) | +| No reply arrives after `shutdown_write()` | The guest is waiting for end-of-input, and TCP half-close is not carried end to end | Frame the protocol so the guest knows a message is complete without EOF — a newline, a length prefix, or `Content-Length` | +| An `exec` that starts the server returns exit code `0` but nothing listens | The command was backgrounded, so its exit code says nothing about the server | Read the server's log file back out of the box, as [Serve HTTP](/cloud/serve-http) does with `/tmp/server.log` | + +## Next steps + + + + Start a server inside a box and reach it over a tunnel. + + + Publish a box port on your own machine. + + + Read and write bytes for a protocol of your own. + + + Who can reach the box, and what the box can reach. + + diff --git a/cloud/volume-operations.mdx b/cloud/volume-operations.mdx index 22fe64a..a8ff3b0 100644 --- a/cloud/volume-operations.mdx +++ b/cloud/volume-operations.mdx @@ -1,60 +1,47 @@ --- -title: "Operating volumes on BoxLite Cloud" -sidebarTitle: "Volume operations" -description: "Read the volume state the SDK does not expose, and delete a volume knowing that reclamation is asynchronous." +title: "Create, list, inspect, and delete volumes" +sidebarTitle: "Volume CRUD" +description: "The four operations the volume API exposes, one script that runs all of them, and the two behaviours that catch people out: deletion is asynchronous, and size is never reported." --- -Once a volume is carrying data, two questions come up that creating and mounting never raise: **what state is this volume actually in**, and **what happens when I delete it**. Both answers live over REST rather than in the SDK. +The volume API has exactly four operations. This page runs all four end to end, then covers the two behaviours that surprise people. -If you are still setting a volume up, start with [Volumes](/cloud/volumes) instead. +## The four operations -## Prerequisites - -- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). -- The REST URL exported as `BOXLITE_REST_URL`, and a volume you already created. See [Volumes](/cloud/volumes). - -## What the SDK does not tell you - -Two pieces of volume state reach the REST API but stop at the SDK. Read them over REST when you need them. - -| What you want | SDK | REST | +| Operation | SDK call | REST | |---|---|---| -| Whether a volume is ready, creating, or being deleted | Not exposed on `VolumeInfo` | `state` on `GET /v1/volumes` and `GET /v1/volumes/{id}` | -| Why a volume failed | Not exposed on `VolumeInfo` | `error_reason` on `GET /v1/volumes/{id}` | -| Volume size | `size_bytes` / `sizeBytes` is **always empty** | Not reported either — the service does not return a size field | +| **Create** | `rt.volumes.create(name)` — `name` optional | `POST /v1/volumes` | +| **List** | `rt.volumes.list()` | `GET /v1/volumes` | +| **Inspect** | `rt.volumes.get(id)` | `GET /v1/volumes/{id}` | +| **Delete** | `rt.volumes.remove(id, force)` | `DELETE /v1/volumes/{id}` | -`state` is one of `creating`, `ready`, `pending_create`, `pending_delete`, `deleting`, `deleted`, or `error`. + +**There is no update operation.** A volume's name is fixed at creation, and there is no rename, resize, or patch call in the SDK or on the REST API. To change a name, create a new volume and copy the data through a box that mounts both. + -```bash -# Read the state the SDK does not surface. -curl -fsS "${BOXLITE_REST_URL}/v1/volumes/${VOLUME_ID}" \ - -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ - | jq '{id, name, state, error_reason}' -``` +Every operation except create addresses the volume **by id**, not by name. Name is for mounting — see [Reference](/cloud/volume-reference#name-a-volume-and-mount-it-by-that-name). -Creating a volume over REST already waits for it to become ready before returning, so a volume you just created from the SDK or from `POST /v1/volumes` is mountable. Poll `state` when you are adopting a volume you did not just create, or when you are diagnosing one that is not behaving. -## Deletion is asynchronous +## Prerequisites -Removing a volume returns an acknowledgement, not a completed deletion — `remove()` returns nothing and `DELETE /v1/volumes/{volume_id}` returns `204`. Immediately afterwards: +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- `pip install boxlite` for Python or `npm install @boxlite-ai/boxlite` for Node, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). +- A REST runtime. Managed volumes need one — a local runtime has no volume backend. -- A REST read of that volume returns `200` with a `state` of `pending_delete` — **not** a `404`. -- A listing can still include the volume for a short window. -- Reclamation finishes on the platform's own cycle. +## All four in one script -So do not write code that waits for a `404`. Poll with a bounded timeout, and treat a volume that has left the listing as done: +This creates a volume, finds it in the listing, reads it back by id, then deletes it and waits for reclamation. ```python Python -# cloud_volume_delete.py — remove a volume, then wait for it to leave the listing -# Run: python cloud_volume_delete.py +# cloud_volume_crud.py — create, list, inspect, delete +# Run: python cloud_volume_crud.py import asyncio import os +import time from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions -VOLUME_ID = os.environ.get("BOXLITE_VOLUME_ID", "") - api_key = os.environ.get("BOXLITE_API_KEY") if not api_key: raise SystemExit("Set BOXLITE_API_KEY to your blk_live_... key before running this.") @@ -68,24 +55,40 @@ async def main() -> None: ) ) - # This script creates no box and no volume, so removal is the only teardown - # it performs — and removal is what it is here to demonstrate. + volume = None try: - # remove() addresses the volume by id, not by name. - await rt.volumes.remove(VOLUME_ID) - print(f"Deletion accepted for {VOLUME_ID}") + # CREATE — name is optional; omit it and the server names the volume after its id. + volume = await rt.volumes.create(f"crud-demo-{int(time.time())}") + print(f"created id={volume.id} name={volume.name} created_at={volume.created_at}") + + # LIST — every volume this key can see. + volumes = await rt.volumes.list() + print(f"listed {len(volumes)} volume(s); ours present: " + f"{any(v.id == volume.id for v in volumes)}") + + # INSPECT — by id, never by name. + same = await rt.volumes.get(volume.id) + # size_bytes is always None on Cloud; measure usage from inside a box instead. + print(f"fetched id={same.id} name={same.name} size_bytes={same.size_bytes}") - # Bounded poll: up to 60s for the volume to leave the listing. + # DELETE — returns an acknowledgement, not a finished deletion. + await rt.volumes.remove(volume.id) + print("delete accepted") + + # Deletion is asynchronous: poll the listing with a bound, never wait for a 404. for _ in range(12): - volumes = await rt.volumes.list() - if all(volume.id != VOLUME_ID for volume in volumes): - print("volume reclaimed") + if all(v.id != volume.id for v in await rt.volumes.list()): + print("reclaimed") return await asyncio.sleep(5) - - print("volume still listed after 60s — reclamation runs on the platform's cycle") + print("still listed after 60s — reclamation runs on the platform's cycle") except Exception as exc: - print(f"volume deletion failed: {exc!r}") + print(f"failed: {type(exc).__name__}: {exc}") + if volume is not None: + try: + await rt.volumes.remove(volume.id, force=True) + except Exception: + pass if __name__ == "__main__": @@ -93,12 +96,10 @@ if __name__ == "__main__": ``` ```typescript Node.js -// cloudVolumeDelete.ts — remove a volume, then wait for it to leave the listing -// Run: node cloudVolumeDelete.ts +// cloudVolumeCrud.ts — create, list, inspect, delete +// Run: node cloudVolumeCrud.ts import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; -const VOLUME_ID = process.env.BOXLITE_VOLUME_ID ?? ""; - const apiKey = process.env.BOXLITE_API_KEY; if (!apiKey) { throw new Error("Set BOXLITE_API_KEY to your blk_live_... key before running this."); @@ -114,24 +115,41 @@ async function main(): Promise { }), ); + let volume = null; try { - // remove() addresses the volume by id, not by name. - await rt.volumes.remove(VOLUME_ID); - console.log(`Deletion accepted for ${VOLUME_ID}`); + // CREATE — name is optional; omit it and the server names the volume after its id. + volume = await rt.volumes.create(`crud-demo-${Math.floor(Date.now() / 1000)}`); + console.log(`created id=${volume.id} name=${volume.name} createdAt=${volume.createdAt}`); + + // LIST — every volume this key can see. + const volumes = await rt.volumes.list(); + console.log(`listed ${volumes.length} volume(s); ours present: ` + + `${volumes.some((v) => v.id === volume.id)}`); + + // INSPECT — by id, never by name. + const same = await rt.volumes.get(volume.id); + // sizeBytes is always undefined on Cloud; measure usage from inside a box instead. + console.log(`fetched id=${same.id} name=${same.name} sizeBytes=${same.sizeBytes}`); + + // DELETE — returns an acknowledgement, not a finished deletion. + await rt.volumes.remove(volume.id); + console.log("delete accepted"); - // Bounded poll: up to 60s for the volume to leave the listing. + // Deletion is asynchronous: poll the listing with a bound, never wait for a 404. for (let attempt = 0; attempt < 12; attempt++) { - const volumes = await rt.volumes.list(); - if (volumes.every((volume) => volume.id !== VOLUME_ID)) { - console.log("volume reclaimed"); + const current = await rt.volumes.list(); + if (current.every((v) => v.id !== volume.id)) { + console.log("reclaimed"); return; } await sleep(5000); } - - console.log("volume still listed after 60s — reclamation runs on the platform's cycle"); + console.log("still listed after 60s — reclamation runs on the platform's cycle"); } catch (err) { - console.error(`volume deletion failed: ${err instanceof Error ? err.message : err}`); + console.error(`failed: ${err instanceof Error ? err.message : err}`); + if (volume) { + await rt.volumes.remove(volume.id, true).catch(() => {}); + } } finally { rt.close(); } @@ -141,48 +159,89 @@ main(); ``` ```bash REST -# Remove a volume, then watch its state until it leaves the listing. +#!/usr/bin/env bash +# cloud_volume_crud.sh — create, list, inspect, delete +set -euo pipefail + BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://app.boxlite.ai/api}" -VOLUME_ID="${BOXLITE_VOLUME_ID:-}" +AUTH=(-H "Authorization: Bearer ${BOXLITE_API_KEY}") + +# CREATE — name is optional. +VOLUME_ID=$(curl -fsS -X POST "${BOXLITE_REST_URL}/v1/volumes" "${AUTH[@]}" \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"crud-demo-$(date +%s)\"}" | jq -r .id) +echo "created ${VOLUME_ID}" + +# LIST +curl -fsS "${BOXLITE_REST_URL}/v1/volumes" "${AUTH[@]}" | jq -r '.volumes[] | "\(.id) \(.name) \(.state)"' + +# INSPECT — state and error_reason are REST-only; the SDK does not expose them. +curl -fsS "${BOXLITE_REST_URL}/v1/volumes/${VOLUME_ID}" "${AUTH[@]}" \ + | jq '{id, name, state, error_reason}' -curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/volumes/${VOLUME_ID}" \ - -H "Authorization: Bearer ${BOXLITE_API_KEY}" -echo "Deletion accepted for ${VOLUME_ID}" +# DELETE — 204, and reclamation continues afterwards. +curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/volumes/${VOLUME_ID}" "${AUTH[@]}" +echo "delete accepted" -# Over REST you can watch the state directly, which the SDK does not expose. +# Watch state rather than waiting for a 404. for _ in $(seq 12); do - STATE=$(curl -fsS "${BOXLITE_REST_URL}/v1/volumes/${VOLUME_ID}" \ - -H "Authorization: Bearer ${BOXLITE_API_KEY}" | jq -r .state 2>/dev/null) - if [ -z "${STATE}" ] || [ "${STATE}" = "deleted" ]; then - echo "volume reclaimed" - exit 0 - fi + STATE=$(curl -fsS "${BOXLITE_REST_URL}/v1/volumes/${VOLUME_ID}" "${AUTH[@]}" | jq -r .state 2>/dev/null || true) + if [ -z "${STATE}" ] || [ "${STATE}" = "deleted" ]; then echo "reclaimed"; exit 0; fi echo "state: ${STATE}" sleep 5 done -echo "volume still present after 60s — reclamation runs on the platform's cycle" +echo "still present after 60s — reclamation runs on the platform's cycle" ``` +To create a volume and actually *use* it, see [Mount a volume](/cloud/mount-a-volume). + +## Deletion is asynchronous + +`remove()` returns nothing and `DELETE /v1/volumes/{id}` returns `204` — both are acknowledgements, not completed deletions. Immediately afterwards: + +- A REST read of that volume returns `200` with a `state` of `pending_delete` — **not** a `404`. +- A listing can still include the volume for a short window. +- Reclamation finishes on the platform's own cycle. + +So never write code that waits for a `404`. Poll with a bounded timeout and treat "gone from the listing" as done, as the script above does. + Deleting a volume that a running box still has mounted does not corrupt that box. The box stays usable. + +## State the SDK does not expose + +Two fields reach the REST API but stop at the SDK. Read them over REST when you need them. + +| What you want | SDK | REST | +|---|---|---| +| Whether a volume is ready, creating, or being deleted | Not on `VolumeInfo` | `state` on `GET /v1/volumes` and `GET /v1/volumes/{id}` | +| Why a volume failed | Not on `VolumeInfo` | `error_reason` on `GET /v1/volumes/{id}` | +| Volume size | `size_bytes` / `sizeBytes` is **always empty** | Not reported either — the service returns no size field | + +`state` is one of `creating`, `ready`, `pending_create`, `pending_delete`, `deleting`, `deleted`, or `error`. + +Creating a volume already waits for it to become ready before returning, so a volume you just created is mountable. Poll `state` when you are adopting a volume you did not create, or diagnosing one that is misbehaving. + ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| -| You cannot tell whether a volume is ready | `state` is not on `VolumeInfo` | Read `state` over REST — see [What the SDK does not tell you](#what-the-sdk-does-not-tell-you) | -| `size_bytes` is always empty | The service does not report volume size on any response | Measure usage from inside a box, for example `du -sh /data` | -| A volume you deleted is still returned | Deletion is asynchronous — a REST read returns `state` `pending_delete` and the listing can lag | Poll with a bounded timeout instead of waiting for a `404` | -| A volume sits in `error` | The backend could not provision it | Read `error_reason` on `GET /v1/volumes/{id}`, then remove the volume and create a replacement | +| `rt.volumes.get(name)` raises | `get` addresses volumes by id only | Pass `volume.id`. Name is for mounting, not for lookup | +| You want to rename a volume and find no method | There is no update operation on volumes | Create a new volume and copy the data through a box that mounts both | +| You cannot tell whether a volume is ready | `state` is not on `VolumeInfo` | Read `state` over REST — see [State the SDK does not expose](#state-the-sdk-does-not-expose) | +| `size_bytes` is always empty | The service reports no volume size on any response | Measure from inside a box, for example `du -sh /data` | +| A volume you deleted is still returned | Deletion is asynchronous — a read returns `pending_delete` and the listing can lag | Poll with a bounded timeout instead of waiting for a `404` | +| A volume sits in `error` | The backend could not provision it | Read `error_reason` on `GET /v1/volumes/{id}`, then remove it and create a replacement | | `401` on `/v1/volumes` | Missing, malformed, or expired API key | Send `Authorization: Bearer ` with a key from the console — see [API keys](/cloud/api-keys) | ## Next steps - - Creating, naming, and mounting a volume, plus what survives the box. + + Create one and actually use it: mount, write, read back. - - The lifecycle switches that decide when a box — and its unsaved data — goes away. + + Every parameter and return shape, name-versus-id addressing, and read-only mounts. diff --git a/cloud/volume-reference.mdx b/cloud/volume-reference.mdx new file mode 100644 index 0000000..452255b --- /dev/null +++ b/cloud/volume-reference.mdx @@ -0,0 +1,158 @@ +--- +title: "Volume reference" +sidebarTitle: "Reference" +description: "How a volume is addressed by name or id, the parameters and return shapes of every volume call, the mount tuple, read-only behaviour, and what differs from open source." +--- + +One page for everything the volume API does, so the task pages can stay about the task. If you have not created a volume yet, start with [Mount a volume](/cloud/mount-a-volume). + +## Prerequisites + +- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). +- A REST runtime. Managed volumes need one — a local runtime has no volume backend to resolve a reference against. + +## Name a volume and mount it by that name + +A volume has both a server-assigned `id` and a `name`, and **either one mounts it**. When you create a volume without a name, the server uses the id as the name. + +Choosing your own name is what lets a worker mount the volume it wants without knowing the id. The two halves can live in different processes that never exchange an id: + + + +```python Python +# In the process that provisions storage: +volume = await rt.volumes.create("training-data") + +# In a worker that only knows the name: +box = await rt.create(BoxOptions(image=IMAGE, volumes=[("training-data", "/data")])) +``` + +```typescript Node.js +// In the process that provisions storage: +const volume = await rt.volumes.create("training-data"); + +// In a worker that only knows the name: +const box = await rt.create({ image: IMAGE, volumes: [["training-data", "/data"]] }); +``` + +```bash REST +# The wire field is managed_volume, and it accepts a name or an id. +curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes" \ + -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d '{"image":"'"${IMAGE}"'","volumes":[{"managed_volume":"training-data","guest_path":"/data"}]}' +``` + + + +Names are unique within your organization, so a name is a stable address across processes and across time. An id is stable too, but you have to carry it somewhere. + + +## Parameters and returns + +The runtime you build with `Boxlite.rest(...)` carries a volumes API. `volumes` is a **property**, so write `rt.volumes` — no parentheses — and await the four methods hanging off it. + +| Call | Parameters | Returns | +|---|---|---| +| `rt.volumes` | Not awaited — a property on the runtime | The volumes handle the four methods below live on | +| `rt.volumes.create(name=None)` | `name`: `str`, optional. Mountable in place of the id; the server names the volume after its id when omitted | `VolumeInfo` for the new volume | +| `rt.volumes.list()` | None | `list[VolumeInfo]` | +| `rt.volumes.get(id)` | `id`: `str`, required | `VolumeInfo`. Raises when no volume has that id | +| `rt.volumes.remove(id, force=False)` | `id`: `str`, required. `force`: `bool`, optional, default `False` | `None` | + +In Node the same four methods are `create(name?)`, `list()`, `get(id)`, and `remove(id, force?)`. + +`VolumeInfo` fields are read-only: + +| Field | Python | Node | Description | +|---|---|---|---| +| id | `id` | `id` | Server-assigned. Mounts the volume, and addresses `get()` and `remove()` | +| name | `name` | `name` | Yours if you passed one to `create()`, otherwise the id. Also mounts the volume | +| created at | `created_at` | `createdAt` | RFC 3339 string | +| size | `size_bytes` | `sizeBytes` | **Always empty on Cloud** — the service does not report volume size on either the list or the single-volume response. See [Volume operations](/cloud/volume-operations#state-the-sdk-does-not-expose) | + +To work with a volume you already have, pass its name or id straight to the box's `volumes` field, or call `get()` first to confirm it exists. + + +## Mount a volume into a box + +Mounting is configured at creation time through the `volumes` field on the box options. Each element is a `(volume, mount_path)` pair. + +**The first element is a managed volume's name or id — not a path on your machine.** That is the mental switch to make coming from open source. + +The mount path has to be an absolute path that is not the root and not a system directory. The service rejects the box otherwise: + +| Mount path | Result | +|---|---| +| `/data`, `/mnt/models`, `/srv/cache` | Accepted | +| `data` or any relative path | Rejected — must be absolute | +| `/` or `//` | Rejected — cannot mount to the root directory | +| `/data/../etc` | Rejected — cannot contain relative path components | +| `/data//cache` | Rejected — cannot contain consecutive slashes | +| `/proc` `/sys` `/dev` `/boot` `/etc` `/bin` `/sbin` `/lib` `/lib64`, or anything under them | Rejected — cannot mount to a system directory | + +For the full box options table and `exec` semantics, see the [Python SDK reference](/reference/python) or the [Node.js SDK reference](/reference/nodejs). For host-directory mount forms, see [Volumes and mounts](/manage-sandbox/volumes). + + +## Read-only mounts + +Read-only managed volumes are not supported. The SDK refuses the mount before the request leaves your process, rather than mounting it writable and letting you believe it is protected: + +```text +read-only managed volumes are not supported yet; mount "training-data" read-write +``` + +Mount read-write and enforce read-only behaviour in your own code, or use a separate volume for data no box should modify. + + +## What is different from open source + + +**Host bind mounts are rejected over REST.** In open source, `volumes=[("/home/you/data", "/data")]` mounts a directory from your machine. On Cloud that first element must be a managed volume's name or id. The SDK refuses the box **before any network request goes out**: + +```text +host bind mounts are only supported by the local runtime; mount a managed volume by id or name instead +``` + +So the mistake surfaces immediately, rather than as a box that starts with an empty mount. If you are porting code, replace every host path with a managed volume reference. + + +| | Open source | Cloud | +|---|---|---| +| What you mount | A directory on the host machine | A managed volume, by name or id | +| Where the data lives | Your filesystem | Managed storage in the BoxLite resource pool | +| Creating storage | Make a directory | `create(name)`, or the console's **New Volume** dialog | +| Host bind mounts | Supported | Rejected | +| Read-only mounts | Supported | Not supported | + +For host-directory mount options, read-only mounts, and `copy_in` / `copy_out`, see [Volumes and mounts](/manage-sandbox/volumes). For the complete side-by-side, see [Cloud vs open source](/cloud/vs-opensource). + + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| The box is refused with a host-path error | A path from your machine was passed as the first element. Cloud takes a managed volume reference there | Pass the volume's name or id: `volumes=[("training-data", "/data")]` | +| `read-only managed volumes are not supported yet` | A mount asked for read-only, which Cloud does not support | Mount read-write; the SDK refuses rather than silently mounting writable | +| `volumes tuples must be (host, guest[, read_only])` | A `volumes` entry is a tuple of the wrong length | Give each mount exactly the volume reference and the mount path | +| `volumes entries must be tuple or dict` | A `volumes` entry is neither — most often a bare string | Wrap each mount in a tuple: `volumes=[(volume.name, "/data")]`, not `volumes=[volume.name]` | +| `rt.volumes(...)` fails when you call it | `volumes` is a property on the runtime, not a method | Drop the parentheses: `await rt.volumes.create()` | +| A BoxLite error from `create()`, `list()`, `get()`, or `remove()` | The runtime has no volume backend — a local runtime cannot resolve a managed volume reference | Build the runtime with `Boxlite.rest(...)` against Cloud | +| `Invalid mount path ... (cannot mount to system directory)` | The mount path is `/proc`, `/etc`, `/bin`, or another system directory | Mount somewhere like `/data` or `/mnt/models` | +| `Invalid mount path ... (must be absolute)` | The mount path is relative | Start the path with `/` | +| `get()` raises for an id you expect to exist | `get()` takes the id, not the name — or the volume was already removed | Call `list()` and read the `id` off the `VolumeInfo` you want | +| Files are gone after the box is removed | The data was written outside the mount path, so it lived on the box's own disk | Write under the mount path, for example `/data/results.json` | +| A volume is not ready, is stuck, or will not go away after deletion | Volume state and deletion are covered on their own page | See [Volume operations](/cloud/volume-operations) | +| `401` on `/v1/volumes` | Missing, malformed, or expired API key | Send `Authorization: Bearer ` with a key from the console — see [API keys](/cloud/api-keys) | + + +## Next steps + + + + Create one, mount it, write and read through it. + + + List, inspect, and delete volumes. + + diff --git a/cloud/volumes.mdx b/cloud/volumes.mdx index 58c65db..fba2e52 100644 --- a/cloud/volumes.mdx +++ b/cloud/volumes.mdx @@ -1,531 +1,39 @@ --- -title: "Persistent volumes on BoxLite Cloud" +title: "Volumes" sidebarTitle: "Volumes" -description: "Create a managed volume, give it a name you choose, mount it by name or by id, and keep the data after the box is gone." +description: "Storage that outlives the box that wrote it — create a managed volume, mount it by a name you choose, and keep the data after the box is gone." --- -A box loses everything on its disk when it is destroyed. A volume does not — mount one into a box and the data outlives it. Use a volume for a dataset or model weights you do not want to fetch again, or for an agent's working state that has to survive the box that produced it. +A box loses everything on its disk when it is destroyed. A volume does not. Mount one into a box and the data outlives it. -## Prerequisites +A volume has a server-assigned `id` and a `name`, and **either one mounts it** — so a worker can mount `("training-data", "/data")` without ever looking up an id. -- An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). -- `pip install boxlite` for Python or `npm install @boxlite-ai/boxlite` for Node, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). -- A REST runtime. Managed volumes need one — a local runtime has no volume backend to resolve a volume reference against. + +Managed volumes need a **REST runtime**. A local runtime has no volume backend to resolve a reference against. + -## Quick example - -Create a volume, mount it into a box at `/data`, write a file through the mount, and read it back. This runs as written once the two environment variables are set. - - - -```python Python -# cloud_volume.py — create a named volume, mount it, write and read through it -# Run: python cloud_volume.py -import asyncio -import os -import time - -from boxlite import ( - ApiKeyCredential, - Boxlite, - BoxOptions, - BoxliteRestOptions, -) - -IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" - -api_key = os.environ.get("BOXLITE_API_KEY") -if not api_key: - raise SystemExit("Set BOXLITE_API_KEY to your blk_live_... key before running this.") - - -async def main() -> None: - rt = Boxlite.rest( - BoxliteRestOptions( - url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), - credential=ApiKeyCredential(api_key), - ) - ) - - volume = None - box = None - try: - # rt.volumes is a property. create() takes an optional name that you can - # mount by later, instead of carrying the id around. - volume = await rt.volumes.create(f"demo-{int(time.time())}") - print(f"Created volume {volume.name} (id {volume.id})") - - box = await rt.create( - BoxOptions( - image=IMAGE, - # (managed volume name or id, mount path inside the box) - volumes=[(volume.name, "/data")], - ), - name=f"volume-demo-{int(time.time())}", - ) - await box.start() - - # Write through the mount, not to the box's own disk. - write = await box.exec( - "sh", - args=["-c", "echo 'subtitle model v3' > /data/notes.txt"], - ) - write_result = await write.wait() - if write_result.exit_code != 0: - print(f"write failed with exit code {write_result.exit_code}") - return - - read = await box.exec("cat", args=["/data/notes.txt"]) - content = "" - async for line in read.stdout(): - content += line - read_result = await read.wait() - - print(f"Exit code: {read_result.exit_code}") - print(content) - except Exception as exc: - # Auth failures, creation failures, and local runtimes without a volume - # backend all surface here. - print(f"volume run failed: {exc!r}") - finally: - # Teardown in finally, so a failure above cannot leave a box billing. - if box is not None: - await rt.remove(box.id, force=True) - if volume is not None: - await rt.volumes.remove(volume.id) - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -```typescript Node.js -// cloudVolume.ts — create a named volume, mount it, write and read through it -// Run: node cloudVolume.ts -import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; - -const IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"; - -const apiKey = process.env.BOXLITE_API_KEY; -if (!apiKey) { - throw new Error("Set BOXLITE_API_KEY to your blk_live_... key before running this."); -} - -async function main(): Promise { - const rt = JsBoxlite.rest( - new BoxliteRestOptions({ - url: process.env.BOXLITE_REST_URL ?? "https://app.boxlite.ai/api", - credential: new ApiKeyCredential(apiKey), - }), - ); - - const stamp = Math.floor(Date.now() / 1000); - let volume = null; - let box = null; - try { - // rt.volumes is a property. create() takes an optional name you can mount by. - volume = await rt.volumes.create(`demo-${stamp}`); - console.log(`Created volume ${volume.name} (id ${volume.id})`); - - box = await rt.create( - { - image: IMAGE, - // [managed volume name or id, mount path inside the box] - volumes: [[volume.name, "/data"]], - }, - `volume-demo-${stamp}`, - ); - await box.start(); - - // Write through the mount, not to the box's own disk. - const write = await (await box.exec("sh", ["-c", "echo 'subtitle model v3' > /data/notes.txt"])).wait(); - if (write.exitCode !== 0) { - console.error(`write failed with exit code ${write.exitCode}`); - return; - } - - const read = await box.exec("cat", ["/data/notes.txt"]); - const stdout = await read.stdout(); - let content = ""; - while (true) { - const line = await stdout.next(); - if (line === null) break; - content += line; - } - const result = await read.wait(); - - console.log(`Exit code: ${result.exitCode}`); - console.log(content); - } catch (err) { - // Auth failures, creation failures, and local runtimes without a volume - // backend all surface here. - console.error(`volume run failed: ${err instanceof Error ? err.message : err}`); - } finally { - // Teardown in finally, so a failure above cannot leave a box billing. - if (box !== null) await rt.remove(box.id, true); - if (volume !== null) await rt.volumes.remove(volume.id); - rt.close(); - } -} - -main(); -``` - -```bash REST -# Requires BOXLITE_API_KEY. Create a key in the console: /cloud/api-keys -BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://app.boxlite.ai/api}" -IMAGE="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" -NAME="demo-$(date +%s)" - -# 1. Create a named volume. An empty body creates an unnamed one, whose name -# the server sets to the id. -VOLUME=$(curl -fsS -X POST "${BOXLITE_REST_URL}/v1/volumes" \ - -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ - -H 'Content-Type: application/json' \ - -d "{\"name\":\"${NAME}\"}") -echo "${VOLUME}" | jq '{id, name, state}' - -# 2. Mount it by name. The wire field is managed_volume — it takes a name or an id. -curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes" \ - -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ - -H 'Content-Type: application/json' \ - -d "{\"image\":\"${IMAGE}\",\"volumes\":[{\"managed_volume\":\"${NAME}\",\"guest_path\":\"/data\"}]}" \ - | jq '{id, name}' - -# 3. Clean up when you are done. -curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/volumes/$(echo "${VOLUME}" | jq -r .id)" \ - -H "Authorization: Bearer ${BOXLITE_API_KEY}" -``` - - - -Two things in that script are worth pausing on, and the rest of this page builds on them: the volume carries a **name you chose**, and the box mounts it by that name. - -## When you need a volume - -- **An agent whose state must outlive its box.** A box on Cloud can stop when it goes idle and be deleted after stopping. Anything the agent wrote to the box's own disk goes away with it; anything it wrote through a volume mount is still there for the next box. -- **A large dataset or model you do not want to re-download.** Fetch it once into a volume, then mount that volume into every box that needs it instead of paying the download on each box. -- **Handing results from one box to the next.** One box produces artifacts under the mount path, a later box mounts the same volume and picks them up. -- **A fleet that addresses storage by name.** Workers mount `("training-data", "/data")` without any of them having to look up an id first. - -## Name a volume and mount it by that name - -A volume has both a server-assigned `id` and a `name`, and **either one mounts it**. When you create a volume without a name, the server uses the id as the name. - -Choosing your own name is what lets a worker mount the volume it wants without knowing the id. The two halves can live in different processes that never exchange an id: - - - -```python Python -# In the process that provisions storage: -volume = await rt.volumes.create("training-data") - -# In a worker that only knows the name: -box = await rt.create(BoxOptions(image=IMAGE, volumes=[("training-data", "/data")])) -``` - -```typescript Node.js -// In the process that provisions storage: -const volume = await rt.volumes.create("training-data"); - -// In a worker that only knows the name: -const box = await rt.create({ image: IMAGE, volumes: [["training-data", "/data"]] }); -``` - -```bash REST -# The wire field is managed_volume, and it accepts a name or an id. -curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes" \ - -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ - -H 'Content-Type: application/json' \ - -d '{"image":"'"${IMAGE}"'","volumes":[{"managed_volume":"training-data","guest_path":"/data"}]}' -``` - - - -Names are unique within your organization, so a name is a stable address across processes and across time. An id is stable too, but you have to carry it somewhere. - -## Parameters and returns - -The runtime you build with `Boxlite.rest(...)` carries a volumes API. `volumes` is a **property**, so write `rt.volumes` — no parentheses — and await the four methods hanging off it. - -| Call | Parameters | Returns | -|---|---|---| -| `rt.volumes` | Not awaited — a property on the runtime | The volumes handle the four methods below live on | -| `rt.volumes.create(name=None)` | `name`: `str`, optional. Mountable in place of the id; the server names the volume after its id when omitted | `VolumeInfo` for the new volume | -| `rt.volumes.list()` | None | `list[VolumeInfo]` | -| `rt.volumes.get(id)` | `id`: `str`, required | `VolumeInfo`. Raises when no volume has that id | -| `rt.volumes.remove(id, force=False)` | `id`: `str`, required. `force`: `bool`, optional, default `False` | `None` | - -In Node the same four methods are `create(name?)`, `list()`, `get(id)`, and `remove(id, force?)`. - -`VolumeInfo` fields are read-only: - -| Field | Python | Node | Description | -|---|---|---|---| -| id | `id` | `id` | Server-assigned. Mounts the volume, and addresses `get()` and `remove()` | -| name | `name` | `name` | Yours if you passed one to `create()`, otherwise the id. Also mounts the volume | -| created at | `created_at` | `createdAt` | RFC 3339 string | -| size | `size_bytes` | `sizeBytes` | **Always empty on Cloud** — the service does not report volume size on either the list or the single-volume response. See [Volume operations](/cloud/volume-operations#what-the-sdk-does-not-tell-you) | - - -To work with a volume you already have, pass its name or id straight to the box's `volumes` field, or call `get()` first to confirm it exists. - -## Create a volume in the console - -The console is the other way to create a volume, and the one to use when you want to see what you own. - -1. Open **Volumes** in the console and click **New Volume**. -2. Fill in **Name** — the only field. Pick something you will recognize later, such as `subtitle-models`. -3. Create it, then give it a few seconds to become ready before you mount it. - -The volume now exists independently of any box. You can mount it into a box, destroy that box, and mount it into a different one later. `rt.volumes.list()` and the **Volumes** page report the same set of volumes, and a name set in either place mounts the same way. - -## Mount a volume into a box - -Mounting is configured at creation time through the `volumes` field on the box options. Each element is a `(volume, mount_path)` pair. - -**The first element is a managed volume's name or id — not a path on your machine.** That is the mental switch to make coming from open source. - -The mount path has to be an absolute path that is not the root and not a system directory. The service rejects the box otherwise: - -| Mount path | Result | -|---|---| -| `/data`, `/mnt/models`, `/srv/cache` | Accepted | -| `data` or any relative path | Rejected — must be absolute | -| `/` or `//` | Rejected — cannot mount to the root directory | -| `/data/../etc` | Rejected — cannot contain relative path components | -| `/data//cache` | Rejected — cannot contain consecutive slashes | -| `/proc` `/sys` `/dev` `/boot` `/etc` `/bin` `/sbin` `/lib` `/lib64`, or anything under them | Rejected — cannot mount to a system directory | - -For the full box options table and `exec` semantics, see the [Python SDK reference](/reference/python) or the [Node.js SDK reference](/reference/nodejs). For host-directory mount forms, see [Volumes and mounts](/manage-sandbox/volumes). - -## Data outlives the box - -The property that makes a volume worth using: write through the mount in one box, destroy that box, mount the same volume in a different box, and the data reads back. The volume is backed by managed storage, not by the box. - - - -```python Python -import asyncio -import os -import time - -from boxlite import ( - ApiKeyCredential, - Boxlite, - BoxliteRestOptions, - BoxOptions, -) - -# A name is easier to carry between processes than an id. -VOLUME = os.environ.get("BOXLITE_VOLUME", "") -IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" - - -async def run_in_fresh_box(rt, name, script): - """Create a box with the volume mounted, run one shell script, then remove the box.""" - box = await rt.create( - BoxOptions(image=IMAGE, volumes=[(VOLUME, "/data")]), - name=name, - ) - try: - await box.start() - execution = await box.exec("sh", args=["-c", script]) - output = "" - async for line in execution.stdout(): - output += line - result = await execution.wait() - return result.exit_code, output - finally: - # The box is gone after this line; the volume is not. - await rt.remove(box.id, force=True) - - -async def main(): - rt = Boxlite.rest(BoxliteRestOptions( - url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"), - credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), - )) - stamp = int(time.time()) - - try: - # Box A writes, then is destroyed. - code, _ = await run_in_fresh_box( - rt, - f"volume-writer-{stamp}", - "echo 'produced by box A' > /data/handoff.txt", - ) - if code != 0: - print(f"box A write failed with exit code {code}") - return - - # Box B is a different box on the same volume. - code, output = await run_in_fresh_box( - rt, - f"volume-reader-{stamp}", - "cat /data/handoff.txt", - ) - print(f"box B exit code: {code}") - print(f"box B read back: {output}") - except Exception as exc: - print(f"handoff failed: {exc}") - - -asyncio.run(main()) -``` - -```typescript Node.js -import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; - -// A name is easier to carry between processes than an id. -const VOLUME = process.env.BOXLITE_VOLUME ?? ""; -const IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"; - -// Create a box with the volume mounted, run one shell script, then remove the box. -async function runInFreshBox(rt, name: string, script: string): Promise<[number, string]> { - const box = await rt.create({ image: IMAGE, volumes: [[VOLUME, "/data"]] }, name); - try { - await box.start(); - const execution = await box.exec("sh", ["-c", script]); - const stdout = await execution.stdout(); - let output = ""; - while (true) { - const line = await stdout.next(); - if (line === null) break; - output += line; - } - const result = await execution.wait(); - return [result.exitCode, output]; - } finally { - // The box is gone after this line; the volume is not. - await rt.remove(box.id, true); - } -} - -async function main(): Promise { - const rt = JsBoxlite.rest( - new BoxliteRestOptions({ - url: process.env.BOXLITE_REST_URL ?? "https://app.boxlite.ai/api", - credential: new ApiKeyCredential(process.env.BOXLITE_API_KEY!), - }), - ); - const stamp = Math.floor(Date.now() / 1000); - - try { - // Box A writes, then is destroyed. - const [writeCode] = await runInFreshBox( - rt, - `volume-writer-${stamp}`, - "echo 'produced by box A' > /data/handoff.txt", - ); - if (writeCode !== 0) { - console.error(`box A write failed with exit code ${writeCode}`); - return; - } - - // Box B is a different box on the same volume. - const [readCode, output] = await runInFreshBox(rt, `volume-reader-${stamp}`, "cat /data/handoff.txt"); - console.log(`box B exit code: ${readCode}`); - console.log(`box B read back: ${output}`); - } catch (err) { - console.error(`handoff failed: ${err instanceof Error ? err.message : err}`); - } finally { - rt.close(); - } -} - -main(); -``` - -```bash REST -# Two boxes, one volume: the first writes, the second reads after the first is gone. -BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://app.boxlite.ai/api}" -VOLUME="${BOXLITE_VOLUME:-}" -IMAGE="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" -AUTH=(-H "Authorization: Bearer ${BOXLITE_API_KEY}" -H 'Content-Type: application/json') - -make_box() { - curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes" "${AUTH[@]}" \ - -d "{\"image\":\"${IMAGE}\",\"volumes\":[{\"managed_volume\":\"${VOLUME}\",\"guest_path\":\"/data\"}]}" \ - | jq -r .id -} - -# Box A writes, then is destroyed. -A=$(make_box) -curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes/${A}/exec" "${AUTH[@]}" \ - -d '{"command":"sh","args":["-c","echo '"'"'produced by box A'"'"' > /data/handoff.txt"]}' > /dev/null -curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/boxes/${A}?force=true" "${AUTH[@]}" - -# Box B is a different box on the same volume. -B=$(make_box) -curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes/${B}/exec" "${AUTH[@]}" \ - -d '{"command":"cat","args":["/data/handoff.txt"]}' -curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/boxes/${B}?force=true" "${AUTH[@]}" -``` - - - -Only what you write **under the mount path** survives. A file written to the box's own filesystem outside `/data` goes away with the box. - -## Read-only mounts - -Read-only managed volumes are not supported. The SDK refuses the mount before the request leaves your process, rather than mounting it writable and letting you believe it is protected: - -```text -read-only managed volumes are not supported yet; mount "training-data" read-write -``` - -Mount read-write and enforce read-only behaviour in your own code, or use a separate volume for data no box should modify. - -## What is different from open source - - -**Host bind mounts are rejected over REST.** In open source, `volumes=[("/home/you/data", "/data")]` mounts a directory from your machine. On Cloud that first element must be a managed volume's name or id. The SDK refuses the box **before any network request goes out**: - -```text -host bind mounts are only supported by the local runtime; mount a managed volume by id or name instead -``` - -So the mistake surfaces immediately, rather than as a box that starts with an empty mount. If you are porting code, replace every host path with a managed volume reference. - - -| | Open source | Cloud | -|---|---|---| -| What you mount | A directory on the host machine | A managed volume, by name or id | -| Where the data lives | Your filesystem | Managed storage in the BoxLite resource pool | -| Creating storage | Make a directory | `create(name)`, or the console's **New Volume** dialog | -| Host bind mounts | Supported | Rejected | -| Read-only mounts | Supported | Not supported | - -For host-directory mount options, read-only mounts, and `copy_in` / `copy_out`, see [Volumes and mounts](/manage-sandbox/volumes). For the complete side-by-side, see [Cloud vs open source](/cloud/vs-opensource). - -## Troubleshooting - -| Symptom | Cause | Fix | -|---|---|---| -| The box is refused with a host-path error | A path from your machine was passed as the first element. Cloud takes a managed volume reference there | Pass the volume's name or id: `volumes=[("training-data", "/data")]` | -| `read-only managed volumes are not supported yet` | A mount asked for read-only, which Cloud does not support | Mount read-write; the SDK refuses rather than silently mounting writable | -| `volumes tuples must be (host, guest[, read_only])` | A `volumes` entry is a tuple of the wrong length | Give each mount exactly the volume reference and the mount path | -| `volumes entries must be tuple or dict` | A `volumes` entry is neither — most often a bare string | Wrap each mount in a tuple: `volumes=[(volume.name, "/data")]`, not `volumes=[volume.name]` | -| `rt.volumes(...)` fails when you call it | `volumes` is a property on the runtime, not a method | Drop the parentheses: `await rt.volumes.create()` | -| A BoxLite error from `create()`, `list()`, `get()`, or `remove()` | The runtime has no volume backend — a local runtime cannot resolve a managed volume reference | Build the runtime with `Boxlite.rest(...)` against Cloud | -| `Invalid mount path ... (cannot mount to system directory)` | The mount path is `/proc`, `/etc`, `/bin`, or another system directory | Mount somewhere like `/data` or `/mnt/models` | -| `Invalid mount path ... (must be absolute)` | The mount path is relative | Start the path with `/` | -| `get()` raises for an id you expect to exist | `get()` takes the id, not the name — or the volume was already removed | Call `list()` and read the `id` off the `VolumeInfo` you want | -| Files are gone after the box is removed | The data was written outside the mount path, so it lived on the box's own disk | Write under the mount path, for example `/data/results.json` | -| A volume is not ready, is stuck, or will not go away after deletion | Volume state and deletion are covered on their own page | See [Volume operations](/cloud/volume-operations) | -| `401` on `/v1/volumes` | Missing, malformed, or expired API key | Send `Authorization: Bearer ` with a key from the console — see [API keys](/cloud/api-keys) | - -## Next steps +## In this section - - Reading volume state over REST, and deleting a volume when reclamation is asynchronous. + + Create a volume, mount it into a box, write through it and read it back. **Start here.** - - Images, sizes, and the console lifecycle switches that make a volume necessary. + + Write from one box, delete that box, read the same bytes from a new one. - - Every behavioural difference in one table, including mounts. + + Create, list, inspect, and delete — all four operations in one script, plus why deletion is asynchronous. + + + Name-versus-id addressing, every parameter and return shape, read-only mounts, and what differs from open source. + +## When you need a volume + +- **An agent whose state must outlive its box.** A Cloud box can stop when idle and be deleted after stopping. Anything written to the box's own disk goes with it; anything written through a volume mount is there for the next box. +- **A large dataset or model you do not want to re-download.** Fetch it once into a volume, then mount that volume into every box that needs it. +- **Handing results from one box to the next.** One box produces artifacts under the mount path, a later box mounts the same volume and picks them up. +- **A fleet that addresses storage by name.** Workers mount a name, and none of them needs an id. + +A volume also costs less than keeping a stopped box around: a mounted volume adds nothing to a box's hourly price, while a stopped box is billed for its disk until it is deleted. See [What a box costs](/cloud/box-costs). diff --git a/cloud/vs-opensource.mdx b/cloud/vs-opensource.mdx index f0a56af..e657556 100644 --- a/cloud/vs-opensource.mdx +++ b/cloud/vs-opensource.mdx @@ -23,7 +23,7 @@ This page is the single place where those differences are enumerated. Every othe | Snapshots, clone, export, import | Available — see [Snapshots and clones](/manage-sandbox/snapshots) | Disabled. The hosted service reports `snapshots_enabled`, `clone_enabled`, `export_enabled`, and `import_enabled` as false | | Lifecycle management | Fully manual — you start, stop, and remove every box | Idle-stop, resume-on-access, and delete-after-stopping, set in the console or with `auto_stop` / `auto_resume` / `auto_delete` — see [Boxes](/cloud/boxes) | | Image operations | `boxlite pull` and the `images` API are available | Not supported over REST. Shared Linux base images are available automatically to keys with Boxes access | -| Resource limits | Whatever your hardware allows | Per-box ceilings and a plan-level concurrency limit — see [Plans, wallet, and usage](/cloud/billing) | +| Resource limits | Whatever your hardware allows | Per-box ceilings, plus a plan-level concurrency limit — see [Managing billing](/cloud/billing#per-box-resource-ceilings) | | Cost | Free software; you pay for your own servers | Metered — a subscription quota is consumed first, and a wallet funds the rest | ### What the table compresses @@ -34,7 +34,7 @@ This page is the single place where those differences are enumerated. Every othe **Snapshots and clones do not carry over.** If your self-hosted code saves disk state with `box.snapshot`, clones a box, or exports one to an archive, that part does not run on Cloud — the hosted service has those four capabilities disabled. Keep state you need to survive a box on a volume instead, which is the durable path on Cloud anyway. -**A Cloud box has a lifecycle policy; a self-hosted box does not.** Cloud can stop a box when it goes idle, resume it when you reach for it again, and delete it after it has been stopped for a while. Set the policy in the console, or from code with `auto_stop` and `auto_delete` (both in seconds, `0` to disable) and `auto_resume`. The defaults differ by route: the console starts at a 15-minute idle stop, while a box created over the API gets no auto-stop unless you ask for one — see [Boxes](/cloud/boxes). +**A Cloud box has a lifecycle policy; a self-hosted box does not.** Cloud can stop a box when it goes idle, resume it when you reach for it again, and delete it after it has been stopped for a while. Set the policy in the console, or from code with `auto_stop` and `auto_delete` (both in seconds, `0` to disable) and `auto_resume`. The defaults are the platform's either way: a 15-minute idle stop, no auto-delete, and resume on access — so a box created over the API stops itself after 15 idle minutes unless you pass `auto_stop` explicitly. See [Boxes](/cloud/box-lifecycle). **Images are prepared for you.** Because image operations are not supported over REST, you do not pull on Cloud. Pick one of the console's base images, or the default `ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0`, and let the shared Linux base images that come with a Boxes-scoped key do the rest. @@ -177,7 +177,7 @@ This one catches people who came from the open-source reference server. That ser | `BoxOptions` rejects `idle_timeout` or `wake_on_access` as unknown fields | Those are not the field names | Use `auto_stop`, `auto_resume`, and `auto_delete` — see [Boxes](/cloud/boxes) | | `boxlite pull` and image APIs report that the operation is unsupported | Image operations are not supported over REST | Use a console base image or `ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0` | | HTTP 401 on every call | The key is missing, wrong, or expired | Check `BOXLITE_API_KEY` and the key's expiry — see [API keys](/cloud/api-keys) | -| Box creation fails once a number of boxes are already running | You reached your plan's concurrency limit | Remove idle boxes, or change plan — see [Plans, wallet, and usage](/cloud/billing) | +| Box creation fails once a number of boxes are already running | You reached your plan's concurrency limit | Remove idle boxes, or change plan — see [Wallet or a plan?](/cloud/plans#concurrency-is-a-separate-decision) | | A long-running job stops partway through | The box hit its idle timeout — work inside the box does not count as activity | Adjust the idle switches for that box — see [Boxes](/cloud/boxes) | ## Next diff --git a/docs.json b/docs.json index 4d9bef3..54e3a01 100644 --- a/docs.json +++ b/docs.json @@ -189,14 +189,22 @@ "group": "Boxes", "icon": "cube", "root": "cloud/boxes", - "pages": [] + "pages": [ + "cloud/box-images", + "cloud/box-sizes", + "cloud/box-lifecycle", + "cloud/box-from-code" + ] }, { "group": "Volumes", "icon": "database", "root": "cloud/volumes", "pages": [ - "cloud/volume-operations" + "cloud/mount-a-volume", + "cloud/volume-operations", + "cloud/data-across-boxes", + "cloud/volume-reference" ] }, { @@ -204,14 +212,23 @@ "icon": "globe", "root": "cloud/network", "pages": [ + "cloud/tunnels", + "cloud/serve-http", + "cloud/port-forwarding", + "cloud/raw-streams", "cloud/network-policy" ] }, { - "group": "Billing", + "group": "Pricing", "icon": "credit-card", - "root": "cloud/billing", - "pages": [] + "root": "cloud/pricing", + "pages": [ + "cloud/box-costs", + "cloud/cost-controls", + "cloud/plans", + "cloud/billing" + ] } ] }, diff --git a/llms.txt b/llms.txt index f244245..8546276 100644 --- a/llms.txt +++ b/llms.txt @@ -98,18 +98,33 @@ - [API keys and authentication](/cloud/api-keys.mdx): Create a BoxLite Cloud API key, hand it to the SDK, CLI, or curl through the environment, and rotate it without downtime. ### Boxes -- [Configure a box on BoxLite Cloud](/cloud/boxes.mdx): Pick an image and a size for a Cloud box, and understand the three lifecycle controls the platform applies while it runs. +- [Boxes](/cloud/boxes.mdx): A box on BoxLite Cloud is a microVM you address by name or id from anywhere your API key reaches — choose its image and size, and know when… +- [Choose an image for a Cloud box](/cloud/box-images.mdx): The three images the console offers, what an image reference looks like from code, and which one to start from. +- [Choose a size for a Cloud box](/cloud/box-sizes.mdx): The three preset sizes, the per-organization ceilings that bound any single box, and the SDK fields that set them. +- [Box lifecycle on Cloud](/cloud/box-lifecycle.mdx): The three controls that decide when a Cloud box stops, whether it wakes again, and when it is deleted — and the idle rule that can end a job… +- [Create, reuse, and remove a box from code](/cloud/box-from-code.mdx): The full create-start-use-remove cycle over REST, reusing a box by name, managing one from the console, and passing environment variables. ### Volumes -- [Persistent volumes on BoxLite Cloud](/cloud/volumes.mdx): Create a managed volume, give it a name you choose, mount it by name or by id, and keep the data after the box is gone. -- [Operating volumes on BoxLite Cloud](/cloud/volume-operations.mdx): Read the volume state the SDK does not expose, and delete a volume knowing that reclamation is asynchronous. +- [Volumes](/cloud/volumes.mdx): Storage that outlives the box that wrote it — create a managed volume, mount it by a name you choose, and keep the data after the box is gon… +- [Create a volume and mount it into a box](/cloud/mount-a-volume.mdx): Create a managed volume, mount it into a box at a path you choose, and write and read through the mount. +- [Create, list, inspect, and delete volumes](/cloud/volume-operations.mdx): The four operations the volume API exposes, one script that runs all of them, and the two behaviours that catch people out: deletion is asyn… +- [Keep data when the box is gone](/cloud/data-across-boxes.mdx): Write to a volume from one box, delete that box, and read the same data back from a new one — the reason managed volumes exist. +- [Volume reference](/cloud/volume-reference.mdx): How a volume is addressed by name or id, the parameters and return shapes of every volume call, the mount tuple, read-only behaviour, and wh… ### Network -- [Reach a service running inside a box](/cloud/network.mdx): Open a tunnel to a port inside a Cloud box to reach an HTTP server, a WebSocket endpoint, or any TCP service — and understand the box's outb… +- [Network](/cloud/network.mdx): Reach a service running inside a Cloud box, and control what that box is allowed to reach on the way out. +- [How tunnels work](/cloud/tunnels.mdx): The one API behind every way of reaching into a Cloud box: how a tunnel is established, why each one carries a single connection, what it is… +- [Serve HTTP from a box and get a public URL](/cloud/serve-http.mdx): Start an HTTP server inside a Cloud box, open a tunnel to its port, and use the public URL the tunnel gives you. +- [Forward a box port to a local port](/cloud/port-forwarding.mdx): Publish a port inside a Cloud box on an address on your own machine, so any TCP client can reach it without knowing about BoxLite. +- [Read and write raw bytes over a tunnel](/cloud/raw-streams.mdx): Drive a tunnel as a bidirectional byte stream when you are speaking a protocol of your own rather than HTTP. - [Control inbound and outbound access to a box](/cloud/network-policy.mdx): Decide who may reach a Cloud box — keep it private, share one port through a signed link, or make it public — and set what the box itself is… -### Billing -- [Plans, wallet, and usage](/cloud/billing.mdx): How BoxLite Cloud charges for boxes: a plan gives you included quota and a concurrency limit each cycle, and your prepaid wallet funds every… +### Pricing +- [Pricing](/cloud/pricing.mdx): What BoxLite Cloud charges for: three metered resources billed by the hour, funded by a prepaid wallet or a plan's included quota. +- [What a box costs](/cloud/box-costs.mdx): The three metered rates, the formula they combine into, what the standard sizes come to per hour and per month, and exactly which hours are… +- [Stop paying for boxes you have finished with](/cloud/cost-controls.mdx): The two lifecycle controls that decide how long each half of a box’s bill runs, and the volume pattern that keeps data without keeping a dis… +- [Wallet or a plan?](/cloud/plans.mdx): Two ways to fund usage on BoxLite Cloud, the usage level at which a subscription starts costing less than paying from your wallet, and the c… +- [Managing billing](/cloud/billing.mdx): Fund your wallet, set automatic reload so boxes never stop for lack of balance, read what you have actually been charged, and know the per-b… ## Use cases