diff --git a/README.md b/README.md index 75fb4d5..87aa302 100644 --- a/README.md +++ b/README.md @@ -1,357 +1,415 @@ -# NeevAI Python SDK +# NeevCloud SDK for Python -Official Python client for the [NeevCloud](https://neevcloud.com) AI platform. Use it to -provision sandboxes, run commands, manage files, and integrate with agent workflows. +The official Python client for NeevCloud, published as `neevai`. -## Prerequisites +[![PyPI](https://img.shields.io/pypi/v/neevai)](https://pypi.org/project/neevai/) +[![Python](https://img.shields.io/pypi/pyversions/neevai)](https://pypi.org/project/neevai/) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE) +[![CI](https://github.com/NeevCloudAI/neev-sdk-python/actions/workflows/python-ci.yml/badge.svg)](https://github.com/NeevCloudAI/neev-sdk-python/actions/workflows/python-ci.yml) -- **Python ≥ 3.10** -- **Supported OS:** Windows, macOS, Linux -- **[uv](https://docs.astral.sh/uv/)** (recommended for running examples from this repo; optional if you use pip and a virtual environment) +One package for the NeevCloud products you build with: Sandboxes, Agents and Agent tools +today, with more to come. It works sync or async, with one client, one API key, and +typed requests, responses and errors. -See [`docs/getting-started.md`](docs/getting-started.md) for per-OS `uv` install commands and a full walkthrough. +## Contents -## Installation +- [Install](#install) +- [Quick start](#quick-start) +- [Products](#products) +- [Sandboxes](#sandboxes) +- [Agents](#agents) +- [Agent tools](#agent-tools) +- [Errors](#errors) +- [Configuration](#configuration) +- [Examples](#examples) +- [Documentation](#documentation) +- [Security](#security) +- [License](#license) + +## Install ```bash pip install neevai +# or +uv add neevai ``` -### Install from source (contributors) +Requires Python 3.10 or later, on Linux, macOS or Windows. + +## Quick start -Clone the repository and install in editable mode with [uv](https://docs.astral.sh/uv/): +Set your credentials: ```bash -git clone https://github.com/NeevCloudAI/neev-sdk-python.git -cd neev-sdk-python -uv sync +export NEEV_API_KEY="your-api-key" +export NEEV_ORG_ID="your-org-id" +export NEEV_PROJECT_ID="your-project-id" ``` -`uv sync` creates a local environment and installs the package in editable mode. Run examples from the repo root with `uv run python ...`. +Find your organization and project IDs as described in [Organizations and projects](https://docs.ai.neevcloud.com/getting-started/org-and-project#find-your-organization-id-and-project-id). -## Configure credentials +Create a sandbox, run a command, read a file, and clean up: -Set these environment variables before running scripts, or pass equivalent kwargs to `NeevAI(...)` / `AsyncNeevAI(...)`. +```python +from neevai import NeevAI -| Variable | Purpose | -| -------- | ------- | -| `NEEV_API_KEY` | Bearer token (**required**) | -| `NEEV_ORG_ID` | Default organization ID | -| `NEEV_PROJECT_ID` | Default project ID | -| `NEEV_BASE_URL` | Base URL (default: `https://api.ai.neevcloud.com/agent`) | -| `NEEV_SANDBOX_TEMPLATE_ID` | Optional sandbox template id (defaults to `sb-ubuntu-26-04-dev` in examples) | -| `NEEV_AGENT_TEMPLATE` | Optional agent template **name** for `create_agent.py` (default: `claude-code`) | +with NeevAI() as client: # reads the NEEV_* variables + sandbox = client.sandboxes.create({"sandbox_template_id": "sb-ubuntu-26-04-dev"}) + try: + sandbox.wait_until_ready() + result = sandbox.exec(["python3", "-c", "print(6 * 7)"]) + print(result.stdout) # 42 + + sandbox.files.write("hello.txt", "hello from NeevCloud\n") + print(sandbox.files.read_text("hello.txt")) + finally: + sandbox.delete() +``` -**Linux / macOS (bash/zsh)** — current session: +`AsyncNeevAI` offers the same API with `async`/`await`. See +[`async_sandbox.py`](examples/async_sandbox.py). -```bash -export NEEV_API_KEY="your-api-key" -export NEEV_ORG_ID="org-abc123" -export NEEV_PROJECT_ID="proj-xyz789" -``` +## Products -**Windows PowerShell** — current session: +| Product | What it is | Entry point | Guide | +| --- | --- | --- | --- | +| Sandboxes | Isolated machines that run your code, commands and servers | `client.sandboxes`, `client.templates` | [Sandboxes](#sandboxes) | +| Agents | Ready-made coding agents, such as Claude Code, each on its own sandbox | `client.agents`, `client.agent_templates` | [Agents](#agents) | +| Agent tools | A sandbox tool set, with model-ready schemas, for the agent you build | `neevai.agent_tools` | [Agent tools](#agent-tools) | -```powershell -$env:NEEV_API_KEY = "your-api-key" -$env:NEEV_ORG_ID = "org-abc123" -$env:NEEV_PROJECT_ID = "proj-xyz789" -``` +## Sandboxes -**Windows CMD** — current session: +A sandbox is an isolated machine for running code, commands and servers. You create it +from a template, work inside it, and pause or delete it when you are done. -```cmd -set NEEV_API_KEY=your-api-key -set NEEV_ORG_ID=org-abc123 -set NEEV_PROJECT_ID=proj-xyz789 -``` +| Capability | API | Example | +| --- | --- | --- | +| [Sandbox lifecycle](#sandbox-lifecycle) | `client.sandboxes.create / list / get / update / pause / resume / delete` | [`sandbox_lifecycle.py`](examples/sandbox_lifecycle.py) | +| [Templates](#templates) | `client.templates.list / get` | [`templates_list.py`](examples/templates_list.py) | +| [Files](#files) | `sandbox.files` | [`files_api.py`](examples/files_api.py) | +| [Commands and processes](#commands-and-processes) | `sandbox.exec()`, `sandbox.processes` | [`processes.py`](examples/processes.py) | +| [Terminals and SSH](#terminals-and-ssh) | `sandbox.pty`, `sandbox.ssh()` | [`pty.py`](examples/pty.py) | +| [Preview URLs](#preview-urls) | `sandbox.get_url()`, `sandbox.expose_port()` | [`preview_ports.py`](examples/preview_ports.py) | +| [Snapshots, fork and rollback](#snapshots-fork-and-rollback) | `sandbox.snapshot() / fork() / rollback()` | [`snapshot_fork_restore.py`](examples/snapshot_fork_restore.py) | +| [Network egress](#network-egress) | `allow_internet`, `allow_egress`, `egress` | [`update_resize_egress.py`](examples/update_resize_egress.py) | +| [Labels](#labels) | `labels=` on create and list | [`sandbox_labels.py`](examples/sandbox_labels.py) | +| [Metrics](#metrics) | `sandbox.metrics()` | [`sandbox_metrics.py`](examples/sandbox_metrics.py) | +| [Audit trail](#audit-trail) | `sandbox.audit()` | [`audit_trail.py`](examples/audit_trail.py) | +| [Code interpreter](#code-interpreter) | `sandbox.code` | [`code_interpreter.py`](examples/code_interpreter.py) | + +### Sandbox lifecycle -You can also pass credentials directly when creating the client: +`create`, `get` and `list` return a `Sandbox` handle. Its lifecycle methods act on that +sandbox, and `get` accepts an id or a name. ```python -from neevai import NeevAI +sandbox = client.sandboxes.create({"name": "build-1", "sandbox_template_id": "sb-ubuntu-26-04-dev"}) +sandbox.wait_until_ready() + +sandbox.pause() # release compute; files and running processes are kept +sandbox.resume() +sandbox.update({"resources": {"cpu": 2, "memory_gb": 4}}) # resize in place -with NeevAI(api_key="...", org_id="...", project_id="...") as client: - ... +page = client.sandboxes.list(status="Paused", limit=50) +sandbox.delete() ``` -## Quick start (from clone) +Lifecycle windows stop a sandbox from running forever. Durations are in seconds. + +| Option | Meaning | +| --- | --- | +| `idle_timeout_seconds` | Act after this long with no activity | +| `on_idle` | `"pause"` or `"delete"` | +| `max_lifetime_seconds` | Hard limit on total lifetime | -If you just cloned the repo, follow these steps to reach your first successful run: +Set them in `lifecycle` at create time, change them with `sandbox.update_timeout(...)`, +and reset the idle timer with `sandbox.keepalive()`. -1. Clone and enter the repo (see [Install from source (contributors)](#install-from-source-contributors) above if you have not already). -2. Install dependencies: `uv sync`. -4. Verify the install: +See [`sandbox_lifecycle.py`](examples/sandbox_lifecycle.py) · [`sandbox_lifecycle_windows.py`](examples/sandbox_lifecycle_windows.py) · [reference](docs/api-reference.md#clientsandboxes) - ```bash - uv run python -c "from neevai import NeevAI; print('ok')" - ``` +### Templates + +A template is the image a sandbox starts from. Omit `sandbox_template_id` to use the +default, or pick one from the catalogue. + +```python +page = client.templates.list() +template = client.templates.get("sb-ubuntu-26-04-dev") +``` -5. Run your first example: +Templates you can start from: - ```bash - uv run python examples/templates_list.py - ``` +| Template | Use it for | +| --- | --- | +| `sb-ubuntu-26-04-dev` | General development: git, gh, Python 3, Node 24 LTS | +| `sb-ubuntu-26-04-docker` | Docker, Compose and Buildx; run `start-docker` first, and run containers with `--network=host` | +| `sb-ubuntu-26-04-interpreter` | The [code interpreter](#code-interpreter): Python kernels that keep their state | +| `sb-ubuntu-26-04-playwright` | Browser testing: a desktop with Node 24, Playwright and Chromium, headless or headed (`DISPLAY=:1`) | -6. **Expected outcome:** the script lists available sandbox templates, fetches one by id, creates a sandbox from it, waits until it is ready, then deletes it. -7. **Next:** read [`docs/getting-started.md`](docs/getting-started.md) for full sync/async quick-start scripts and the documentation map. +The catalogue grows over time, so `templates.list()` is the source of truth. To use your +own image instead, pass `image` (a public OCI image with a tag or digest). -## Minimal code example +Playwright and Chromium come preinstalled on the Playwright template, so a project that +pins the same Playwright version runs its tests with nothing to download: ```python -from neevai import NeevAI +browser = client.sandboxes.create({"sandbox_template_id": "sb-ubuntu-26-04-playwright"}) +browser.wait_until_ready() +run = browser.exec(["npx", "playwright", "test"], cwd="app", timeout_ms=300_000) +``` + +See [`docker.py`](examples/docker.py) · [`byoi_create.py`](examples/byoi_create.py) · [reference](docs/api-reference.md#templates) + +### Files + +Paths are relative to the sandbox workspace, or absolute inside it. -with NeevAI(api_key="...", org_id="...", project_id="...") as client: - sandbox = client.sandboxes.create({}) - sandbox.wait_until_ready() - result = sandbox.exec("echo Hello World") - print(result.stdout) - client.sandboxes.delete(sandbox.id) +```python +sandbox.files.write("main.py", "print('hi')") +text = sandbox.files.read_text("main.py") +entries = sandbox.files.list(".") + +sandbox.files.move("main.py", "app.py") +sandbox.files.remove("build", recursive=True) ``` -## Detecting a crash +Large writes are sent in resumable chunks automatically. `upload_file` and +`download_file` move files between local disk and the sandbox without buffering them, +and `files.watch` streams changes as they happen. + +See [`files_api.py`](examples/files_api.py) · [`upload_download.py`](examples/upload_download.py) · [reference](docs/api-reference.md#files) + +### Commands and processes -A sandbox that hits an OOM kill or another unexpected stop is restarted for you, so it -reads back as `Ready` — but its filesystem may be gone. `last_crash` is how you tell: +`exec` runs a command to completion and returns its output. A non-zero exit code is +returned, not raised. ```python -crash = sandbox.last_crash # None if this sandbox has never crashed -if crash and crash.storage_reset: - # The sandbox restarted with an empty filesystem: files under /workspace, - # and anything installed since create, are gone. Re-provision before using it. - print(f"{crash.reason} at {crash.at}") +result = sandbox.exec(["ls", "-la"]) +print(result.exit_code, result.stdout) + +# Stream output as it is produced. +for event in sandbox.exec_stream(["npm", "test"]): + print(event) ``` -`crash.storage_reset is False` means it restarted with its files intact. The field records -a past event and is **not** cleared when the sandbox recovers, so check `crash.at` before -reacting to it. Restoring from a snapshot taken before a stop that reset storage brings -the files back and clears `last_crash` once the restore completes. Agents expose the same -`agent.last_crash`. See [`last_crash.py`](examples/last_crash.py). +For work that should outlive one call, such as a dev server or a watcher, start a +background process instead: -## Files +```python +server = sandbox.processes.start(["python3", "-m", "http.server", "8000"]) +for event in server.follow(): + if event["type"] == "stdout": + print(event["data"], end="") + break +server.kill() +``` -Paths are relative to the workspace or absolute within it. `files.write` sends content -up to 1 MiB in one request and anything larger in chunks automatically. For large files -on disk, `upload_file` / `download_file` stream without holding the file in memory: +See [`processes.py`](examples/processes.py) · [`streaming_exec.py`](examples/streaming_exec.py) · [reference](docs/api-reference.md#commands-and-processes) + +### Terminals and SSH + +`sandbox.pty` opens an interactive terminal. You send keystrokes and receive output +through a callback. ```python -sandbox.files.write("notes.txt", "hello\n") +term = sandbox.pty.create(program="bash", cols=80, rows=24, + on_data=lambda data: print(data.decode(), end="")) +term.send_input("ls -la\n") +``` -# Chunked and resumable: a chunk lost to a dropped connection continues from the -# last byte the sandbox received. chunk_size is 1 MiB by default (64 KiB-1 MiB). -sandbox.files.upload_file("dataset.parquet", "data/dataset.parquet", - on_progress=lambda sent, total: print(f"{sent}/{total}")) +`sandbox.ssh()` opens a local port that forwards to the sandbox, so `ssh`, `scp`, +`rsync` and IDE remote development work with no keys to manage. -# Written beside the target and moved into place when complete: no partial file. -sandbox.files.download_file("data/dataset.parquet", "copy.parquet") +```python +with sandbox.ssh() as tunnel: + print(f"ssh -p {tunnel.port} neev@localhost") ``` -`files.upload(path, data)` takes bytes, str, or a seekable binary file object. See -[`upload_download.py`](examples/upload_download.py). +See [`pty.py`](examples/pty.py) · [`ssh_tunnel.py`](examples/ssh_tunnel.py) · [reference](docs/api-reference.md#terminals-and-ssh) -## Preview URLs +### Preview URLs -Ports are private until exposed. `get_url(port)` exposes one and returns its public, -credential-free URL. The slug in the URL is the only thing gating it, so treat the URL -as a secret; if it leaks, expose the port again with a new `slug` to rotate it: +Expose a port and get a public HTTPS URL for it. `get_url` waits until the URL answers. ```python +sandbox.processes.start(["python3", "-m", "http.server", "3000"]) url = sandbox.get_url(3000) -sandbox.expose_port(3000, slug="k3x9q2mz") # replaces the slug; the old URL stops working -sandbox.revoke_port(3000) ``` -Agents have the same `expose_port` / `list_ports` / `revoke_port` / `get_url`. +Anyone with the URL can open it, so treat it as a secret. Rotate it with +`sandbox.expose_port(3000, slug=...)`, and close the port with `sandbox.revoke_port(3000)`. + +See [`preview_ports.py`](examples/preview_ports.py) · [reference](docs/api-reference.md#clientsandboxes) -## Audit trail +### Snapshots, fork and rollback -`sandbox.audit()` (and `agent.audit()`) reads what ran inside, newest first — terminal -commands, SSH, process and file operations — as events in `data`, each with who made the call -(`actor`) and how it ended (`status`). Program names are recorded without their arguments. -`time` is milliseconds since the Unix epoch. Filter with `operation`, `status`, `actor_type`, -`actor_uid` or `credential_uid`, and page with `next_cursor`: +A snapshot captures a sandbox's full state: its files and the memory of its running +processes. Roll the sandbox back to it later, fork the +sandbox's current state into a new one, or restore a snapshot into a new sandbox. ```python -trail = sandbox.audit(limit=50, operation=["exec", "pty_command"]) -for e in trail.data: - print(e.time, e.api.operation, e.actor.user.name or e.actor.user.uid, e.status) -older = sandbox.audit(limit=50, cursor=trail.next_cursor) if trail.next_cursor else None +snap = sandbox.snapshot({"name": "checkpoint"}, wait_until_ready=True) +sandbox.rollback(snap.id) # back to the snapshot, in place +copy = sandbox.fork("my-fork") # a new sandbox from the current state +restored = client.sandboxes.create({"restore": snap.id}) # a new sandbox from the snapshot ``` -See [`audit_trail.py`](examples/audit_trail.py). +See [`snapshot_fork_restore.py`](examples/snapshot_fork_restore.py) · [reference](docs/api-reference.md#snapshots-fork-and-rollback) -## Labels +### Network egress -Tag a sandbox with key-value labels at create time, then find it again by them: +A sandbox has no outbound network unless you allow it, apart from any its template +allows (the Docker template allows Docker Hub and GHCR). ```python -client.sandboxes.create( - {"name": "ci", "sandbox_template_id": "..."}, - labels={"team": "payments", "ci-run": "4821"}, -) -page = client.sandboxes.list(labels={"team": "payments"}) # every label must match -print(page.items[0].labels) # {'team': 'payments', 'ci-run': '4821'} +client.sandboxes.create({"sandbox_template_id": "sb-ubuntu-26-04-dev"}, allow_internet=True) +client.sandboxes.create({"sandbox_template_id": "sb-ubuntu-26-04-dev"}, allow_egress=["github.com", "*.pypi.org"]) + +# Edit a running sandbox's allow-list in place. +sandbox.update({"egress_add": {"allow": [{"host": "pypi.org", "ports": [443]}]}}) ``` -A sandbox takes at most 16 labels. Keys are 1-63 lowercase letters, digits, `.`, `_` or -`-`, starting and ending with a letter or digit; values are up to 63 letters, digits, -`.`, `_` or `-`. More than 16 labels or a value over 63 characters raises -`ResponseValidationError` before the request is sent; a key or value in the wrong -format is rejected by the server with `BadRequestError`. +For ports and protocols, pass a full `egress` policy instead. + +See [`update_resize_egress.py`](examples/update_resize_egress.py) · [reference](docs/api-reference.md#clientsandboxes) -## Network egress +### Labels -Sandboxes (and agents) are **deny-all by default** — no outbound network. Open egress at -create time with the convenience keyword args, on either `sandboxes.create` or -`agents.create`: +Tag sandboxes at create time and find them again by label. ```python -# allow the whole internet -client.sandboxes.create({"name": "web", "sandbox_template_id": "..."}, allow_internet=True) +client.sandboxes.create({"sandbox_template_id": "sb-ubuntu-26-04-dev"}, labels={"team": "payments"}) +page = client.sandboxes.list(labels={"team": "payments"}) +``` -# allow only specific hosts (FQDN or CIDR; wildcards supported) -client.sandboxes.create({"name": "ci", "sandbox_template_id": "..."}, allow_egress=["github.com", "*.npmjs.org"]) +See [`sandbox_labels.py`](examples/sandbox_labels.py) · [reference](docs/api-reference.md#clientsandboxes) -# same on agents -client.agents.create({"name": "coder", "agent_template": "claude-code"}, allow_internet=True) +### Metrics + +Read CPU, memory and disk usage over a time window. + +```python +metrics = sandbox.metrics(step="60s") ``` -`allow_internet=True` opens all outbound traffic (`0.0.0.0/0` and `::/0`). For finer control -(ports, protocols, a mix of rules) pass a full `egress` object in `params` instead — it -takes precedence over the convenience args. +See [`sandbox_metrics.py`](examples/sandbox_metrics.py) · [reference](docs/api-reference.md#clientsandboxes) -On a running sandbox or agent, `update` either replaces the policy (`egress`) or edits the -allow-list in place, leaving every other rule untouched: +### Audit trail + +Read what was run inside a sandbox, newest first: commands, terminal and SSH sessions, +and file and process operations. Each event says who made the call and how it ended. +Programs are recorded by name only, never with their arguments. ```python -sandbox.update({ - "egress_add": {"allow": [{"host": "pypi.org", "ports": [443]}]}, - "egress_remove": {"allow": [{"host": "github.com"}]}, -}) +trail = sandbox.audit(operation="exec", status="failure") +for e in trail.data: + print(e.time, e.api.operation, e.actor.user.uid, e.status) ``` -## Errors +Filter with `operation`, `status`, `actor_type`, `actor_uid` or `credential_uid`, and +page back with `cursor=trail.next_cursor`. Events take a few seconds to appear after a +call. -Every failure raises a `NeevAIError` subclass: `NotFoundError` (404), `ConflictError` -(409), `RateLimitError` (429), `ServiceUnavailableError` (503, worth retrying shortly; a -subclass of `InternalServerError`), and so on. `APIError.code` is a machine-readable -classification such as `not_found` or `sandbox_quota_exceeded` — branch on it rather than -on the message text — and `APIError.scope` names the limit a quota refusal hit: +See [`audit_trail.py`](examples/audit_trail.py) · [reference](docs/api-reference.md#clientsandboxes) + +### Code interpreter + +Sandboxes created from `sb-ubuntu-26-04-interpreter` run Python in kernels that keep +their state between runs. ```python -from neevai.errors import APIError +sandbox = client.sandboxes.create({"sandbox_template_id": "sb-ubuntu-26-04-interpreter"}) -try: - client.sandboxes.create({"sandbox_template_id": "sb-ubuntu-26-04-dev"}) -except APIError as e: - if e.code == "sandbox_quota_exceeded": - print(f"quota reached for this {e.scope}") - else: - raise +sandbox.code.run("import pandas as pd\ndf = pd.DataFrame({'x': [1, 2, 3]})") +run = sandbox.code.run("int(df.x.sum())") +print(run.text) # "6" ``` -## Long-running processes +An exception in your code is returned in `run.error`, not raised. Use +`sandbox.code.create_context()` for separate kernels with their own state. -For detached workloads that outlive a single HTTP request, use `sandbox.processes` -(unlike request-scoped `sandbox.exec`). Before `processes.start`, wait for -`connect_url` (it may appear before `phase == "Ready"`), then -`sandbox.wait_until_ready()`, then probe the sandbox runtime with -`sandbox.processes.list()` (retry transient 502/503/504). Tune polling with -`NEEVAI_WAIT_TIMEOUT_MS` and `NEEVAI_POLL_INTERVAL_MS`. See -[Processes API — end-to-end flow](docs/api-inventory.md#processes-api) for auth, -raw endpoints, and troubleshooting. +See [`code_interpreter.py`](examples/code_interpreter.py) · [reference](docs/api-reference.md#code-interpreter) + +## Agents + +An agent is a ready-made coding agent, such as Claude Code, running on its own sandbox. +You choose an agent template, and the SDK manages the agent's life and gives you its +sandbox to work with. + +| Capability | API | Example | +| --- | --- | --- | +| [Agent lifecycle](#agent-lifecycle) | `client.agents.create / list / get / update / pause / resume / delete` | [`create_agent.py`](examples/create_agent.py) | +| [Agent templates](#agent-templates) | `client.agent_templates.list / get` | [`create_agent.py`](examples/create_agent.py) | +| [Preview URLs and audit trail](#preview-urls-and-audit-trail) | `agent.get_url()`, `agent.expose_port()`, `agent.audit()` | [`agent_ports_audit.py`](examples/agent_ports_audit.py) | +| [Working inside the agent's sandbox](#working-inside-the-agents-sandbox) | `agent.sandbox()` | [`create_agent.py`](examples/create_agent.py) | + +### Agent lifecycle + +`create`, `get` and `list` return an `Agent` handle, and `get` accepts an id or a name. ```python -proc = sandbox.processes.start(["sh", "-c", "echo started; sleep 30"]) -for event in proc.follow(): - if event["type"] == "stdout": - print(event["data"], end="") - elif event["type"] == "exit": - print(f"exit {event['exit_code']}") - break -final = proc.wait() +agent = client.agents.create({"name": "my-coder", "agent_template": "claude-code"}) +agent.wait_until_ready() + +agent.update({"idle_timeout_seconds": 1800}) # also resources and egress +agent.pause() +agent.resume() +agent.keepalive() # reset the idle timer +agent.delete() ``` -See [`examples/processes.py`](examples/processes.py) and -[`examples/process_pool.py`](examples/process_pool.py). +`agent.rollback(snapshot_id)` restores the agent's sandbox to a snapshot in place. -## Code interpreter +See [`create_agent.py`](examples/create_agent.py) · [reference](docs/api-reference.md#agents) -A sandbox created from the interpreter template runs Python in persistent kernels. -Variables, imports and loaded data stay between runs in the same context, so each run -builds on the last: +### Agent templates -```python -sandbox = client.sandboxes.create({"sandbox_template_id": "sb-ubuntu-26-04-interpreter"}) -sandbox.wait_until_ready() +An agent template is the agent you run, such as `claude-code`. Pass its name as +`agent_template` when you create an agent. -sandbox.code.run("import pandas as pd\ndf = pd.DataFrame({'x': [1, 2, 3]})") -run = sandbox.code.run( - "print(df.x.sum())\ndf.describe()", - on_stdout=lambda out: print(out.line, end=""), # OutputMessage(line, timestamp, error) -) -run.stdout # "6\n" -run.text # the last expression's text, here the describe() table -run.results[0].html # text, html, markdown, svg, png, jpeg, pdf, latex, json; formats() -run.execution_count # 2 -run.end_reason # "ok" +```python +page = client.agent_templates.list() ``` -Code that raises is returned, not raised: `end_reason` is `"error"` and `run.error` -holds `name`, `value` and `traceback`. A run that outlives `timeout_ms` is interrupted -with `end_reason == "deadline_exceeded"` and the context keeps its state; -`"kernel_restarted"` and `"memory_exceeded"` mean the state was lost, which a change in -`run.generation` also tells you. A run on a context that is still busy raises an -`APIError` with `reason == "context_busy"`. A run or context call on a paused sandbox -wakes it and waits up to two minutes, within `request_timeout_ms`. - -Run options: `context` (a context or its id), `language` (`"python"`, the default -context's language), `envs` (environment variables for this run only, seen by -subprocesses too), `timeout_ms` (the cell's timeout; the sandbox's ceiling is the -default), `request_timeout_ms` (a bound on the whole request), and the `on_stdout` / -`on_stderr` / `on_result` / `on_error` callbacks. `run.logs` holds the output pieces as -they arrived. +See [`create_agent.py`](examples/create_agent.py) · [reference](docs/api-reference.md#agent-templates) + +### Preview URLs and audit trail + +Agents expose ports and keep an audit trail the same way sandboxes do. ```python -sandbox.code.run("import os\nprint(os.environ['STAGE'])", envs={"STAGE": "test"}) +url = agent.get_url(3000) +trail = agent.audit(limit=50) ``` -Each context is a separate kernel with its own state, started in its own working -directory; a run sent while its kernel is still starting waits for it. Runs without a -`context` use the `default` context: +See [`agent_ports_audit.py`](examples/agent_ports_audit.py) · [reference](docs/api-reference.md#agents) + +### Working inside the agent's sandbox + +`agent.sandbox()` returns the agent's sandbox as a `Sandbox` handle, so files, commands +and processes work exactly as they do on any sandbox. ```python -ctx = sandbox.code.create_context(cwd="project") # CreatedCodeContext(context_id, generation, language, cwd) -sandbox.code.run("import os\nprint(os.getcwd())", context=ctx) -sandbox.code.list_contexts() # [CodeContext(context_id, state, generation, language, cwd, rss_mib)] -sandbox.code.restart_context(ctx) # drops its state, returns the new generation -sandbox.code.delete_context(ctx) +sandbox = agent.sandbox() +sandbox.files.write("task.md", "# scratch\n") +result = sandbox.exec(["ls"]) ``` -See [`examples/code_interpreter.py`](examples/code_interpreter.py). +See [`create_agent.py`](examples/create_agent.py) · [reference](docs/api-reference.md#agents) ## Agent tools -`neevai.agent_tools` ships the sandbox tool contract for your own agent: tools -like `run_command`, `write_file`, `read_file`, `stat_path`, `move_path` and -`delete_path`, with model-ready descriptions -and JSON Schemas, capped output, and errors the model can act on returned as -`{"error": ...}`. Preview URLs, checkpoints and background processes are -opt-in. It has no framework dependency: build tool specs from `DESCRIPTIONS` -and `PARAMETERS` (with display titles in `TITLES`), and pass each tool call to -`call(name, args)`. -`AsyncSandboxTools` wraps an `AsyncSandbox` the same way. - -`read_file` returns a whole file cut in the middle past 10 KB, or, given -`offset` and `length`, one page of whole UTF-8 characters with `next_offset` and -`eof` so the model can read a large file in full. `allow_processes=True` adds -`start_process`, `read_process_output`, `stop_process` and `list_processes`, -which lists each process with the command it was started with. - -A command that prints nothing for about 2 minutes is cut off at the network -edge; `run_command` then returns an error result telling the model to use -`start_process`, so give it `allow_processes=True` for long or quiet work -(`start_process` plus `read_process_output` with `wait_s`). +`neevai.agent_tools` gives the agent you build a ready-made set of sandbox tools, with +descriptions and JSON schemas written for a model. Use it with any model API, or +through an adapter for your agent framework. + +| Capability | API | Example | +| --- | --- | --- | +| [The tool contract](#the-tool-contract) | `SandboxTools`, `DESCRIPTIONS`, `PARAMETERS`, `FatalGuard` | [`agent_tools_loop.py`](examples/agent_patterns/agent_tools_loop.py) | +| [Framework adapters](#framework-adapters) | `to_claude`, `to_openai_agents`, `to_langchain` | [`agent_patterns/`](examples/agent_patterns/README.md) | + +### The tool contract ```python import json @@ -360,149 +418,158 @@ from neevai.agent_tools import DESCRIPTIONS, PARAMETERS, SandboxTools tools = SandboxTools(sandbox, allow_processes=True) specs = [ - {"type": "function", "function": {"name": n, "description": DESCRIPTIONS[n], "parameters": PARAMETERS[n]}} + {"name": n, "description": DESCRIPTIONS[n], "parameters": PARAMETERS[n]} for n in tools.names() ] -# ... send specs to your model; for each tool call it returns: -result = tools.call(tool_call.name, json.loads(tool_call.arguments)) # a JSON-serializable dict + +# For each tool call from the model: +result = tools.call(tool_call.name, json.loads(tool_call.arguments)) ``` -See the [tool contract](https://docs.ai.neevcloud.com/build-your-own-agent/tool-contract) -for every tool's arguments and results, and -[`examples/agent_patterns/agent_tools_loop.py`](examples/agent_patterns/agent_tools_loop.py) -for a complete loop. +| Tools | Enabled | +| --- | --- | +| `run_command`, `write_file`, `read_file`, `stat_path`, `move_path`, `delete_path` | Always | +| `expose_port` | `allow_preview=True` | +| `checkpoint`, `rollback` | `allow_rollback=True` | +| `start_process`, `read_process_output`, `stop_process`, `list_processes` | `allow_processes=True` | -### Using the tools with a framework +Mistakes the model can fix come back as `{"error": ...}`. Authentication, rate-limit, +server and network failures raise. `FatalGuard` turns those into one fixed error for the +model, stops later calls, and re-raises the first failure to you. `AsyncSandboxTools` +works with `AsyncSandbox`. -`tools.specs()` gives each enabled tool's name, title, description, JSON -Schema and a bound `call(args)`. The adapters in `neevai.agent_tools` turn -those into one framework's tools, so an example needs no glue of its own. -Install the framework through its extra. +See [`agent_tools_loop.py`](examples/agent_patterns/agent_tools_loop.py) · [reference](docs/api-reference.md#agent-tools) -Claude Agent SDK (`pip install "neevai[claude]"`): an in-process MCP server with -Claude Code's own tools switched off. `watch` stops the run and raises the -first sandbox error, which the SDK would otherwise hand to Claude: +### Framework adapters -```python -from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient -from neevai.agent_tools import SandboxTools -from neevai.agent_tools.claude import to_claude +Adapters turn the tools into a framework's own tools. Install the matching extra. -claude = to_claude(SandboxTools(sandbox)) -async with ClaudeSDKClient(ClaudeAgentOptions(**claude.options())) as client: - await client.query(task) - async for message in claude.watch(client.receive_response()): - print(message) -``` - -OpenAI Agents SDK (`pip install "neevai[openai-agents]"`): +| Framework | Extra | Adapter | +| --- | --- | --- | +| Claude Agent SDK | `neevai[claude]` | `neevai.agent_tools.claude.to_claude` | +| OpenAI Agents SDK | `neevai[openai-agents]` | `neevai.agent_tools.openai_agents.to_openai_agents` | +| LangChain and LangGraph | `neevai[langchain]` | `neevai.agent_tools.langchain.to_langchain` | ```python from agents import Agent, Runner + from neevai.agent_tools import SandboxTools from neevai.agent_tools.openai_agents import to_openai_agents -agent = Agent(name="engineer", tools=to_openai_agents(SandboxTools(sandbox)), model=model) -result = await Runner.run(agent, task) +agent = Agent(name="coder", tools=to_openai_agents(SandboxTools(sandbox))) +result = await Runner.run(agent, prompt) ``` -LangChain and LangGraph (`pip install "neevai[langchain]"`): +A sandbox failure that the model can't fix stops the run and is raised to you. + +See [`agent_patterns/`](examples/agent_patterns/README.md) · [reference](docs/api-reference.md#framework-adapters) + +## Errors + +Every failure is a `NeevAIError`. API failures are `APIError` subclasses with the HTTP +status (`status_code`), a machine-readable `code` and a `request_id` for support. ```python -from langchain.agents import create_agent -from neevai.agent_tools import SandboxTools -from neevai.agent_tools.langchain import to_langchain +from neevai.errors import APIError, NotFoundError -agent = create_agent(model, to_langchain(SandboxTools(sandbox))) -result = agent.invoke({"messages": [{"role": "user", "content": task}]}) +try: + client.sandboxes.get("missing") +except NotFoundError: + ... # handle a missing sandbox +except APIError as e: + print(e.status_code, e.code, e.request_id) ``` -For another framework, wrap each `spec.call` with a `FatalGuard` if the -framework turns a tool's exception into a message for the model: the guard -answers with a fixed error instead, and `guard.raise_if_failed()` raises the -first exception once the run ends. +| Error | When | +| --- | --- | +| `BadRequestError` | 400: invalid request | +| `AuthenticationError` | 401: missing or invalid API key | +| `PermissionDeniedError` | 403: not allowed | +| `NotFoundError` | 404: no such resource | +| `ConflictError` | 409: conflicting state | +| `RateLimitError` | 429: too many requests | +| `InternalServerError` | 5xx (`ServiceUnavailableError` for 503) | +| `APIConnectionError`, `APITimeoutError` | The request did not complete | -## Agents +Branch on `e.code` rather than the message text. Network errors, 429 and 5xx are +retried automatically. Calls inside a sandbox, such as `exec` and `files.write`, are not +retried, because running them twice could repeat their effect. -Provision a packaged agent from the catalogue template (`agent_template` is the -template **name**, e.g. `"claude-code"`), wait for it to become `Ready`, then -reach its environment through the backing sandbox: +## Configuration -```python -from neevai import NeevAI +Pass options to `NeevAI(...)`, or set the matching environment variables. They apply to +every product. -with NeevAI(api_key="...", org_id="...", project_id="...") as client: - agent = client.agents.create({ - "name": "my-agent", - "agent_template": "claude-code", - }) - agent.wait_until_ready() - sandbox = agent.sandbox() - sandbox.files.write("notes.md", "# scratch\n") - agent.update({"resources": {"cpu": 2, "memory_gb": 4}, "idle_timeout_seconds": 1800}) - agent.keepalive() # reset the idle timer while work is in progress - agent.pause() - agent.delete() -``` +| Option | Environment variable | Default | +| --- | --- | --- | +| `api_key` | `NEEV_API_KEY` | Required | +| `org_id` | `NEEV_ORG_ID` | Required for most calls | +| `project_id` | `NEEV_PROJECT_ID` | Required for most calls | +| `timeout_ms` | | `60000` | +| `max_retries` | | `2` | + +Find your organization and project IDs as described in [Organizations and projects](https://docs.ai.neevcloud.com/getting-started/org-and-project#find-your-organization-id-and-project-id). -Agents can also be fetched by name (`client.agents.get("my-agent")`), rolled back to a -snapshot (`agent.rollback(snapshot_id)`), serve preview URLs (`agent.expose_port(port)`), -and read their audit trail (`agent.audit()`). See -[`agent_ports_audit.py`](examples/agent_ports_audit.py). +Every method also accepts `org_id` and `project_id` to override the client's defaults for +one call. -Browse templates with `client.agent_templates.list()` / `.get(id)`. `region` is -optional on agent create. See [`examples/create_agent.py`](examples/create_agent.py). +For an endpoint the SDK does not wrap yet, `client.raw.request(method, path)` sends a +request with the same authentication, retries and errors. ## Examples -Runnable examples live under [`examples/`](examples/). From the repo root, run any example with: +Runnable scripts live in [`examples/`](examples); see its +[README](examples/README.md) for setup and the full list. -```bash -uv run python examples/