# eryx.run API

eryx.run runs untrusted Python in a sandbox: CPython 3.14 compiled to
WebAssembly. Code can't touch your network or filesystem, but it can `await`
functions that you provide, and those run in your own systems.

There are two ways to run code:

- **`POST /v1/execute`** runs some code and returns its output. Use it when the
  code doesn't need to call you back.
- **`/v1/stream`** is a WebSocket. Use it when the code calls functions
  (callbacks) that your app handles while the code runs.

The base URL is `https://api.dev.eryx.run`. Requests and responses are JSON.

## Quick start

1. [Sign in](https://app.dev.eryx.run) with GitHub and create an API key.
2. Run some code:

```bash
export ERYX_API_KEY=<your key>
curl https://api.dev.eryx.run/v1/execute \
  -H "Authorization: Bearer $ERYX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"code": "print(1 + 1)"}'
```

```json
{"stdout": "2\n", "stderr": "", "compute_units": 372479, "status": "success"}
```

## Authentication

Send your API key as a Bearer token on every request, including the WebSocket
upgrade:

```text
Authorization: Bearer eryx_...
```

Create and revoke keys in the [dashboard](https://app.dev.eryx.run). Each key is shown
once, when you create it. An org can have up to 10 active keys. A revoked key
can keep working for up to a minute.

Keys belong to your org, and so do the [limits](#limits) and usage: every key
in an org shares them.

## Run code

`POST /v1/execute` runs the code and responds when it has finished.

### Request

| Field | Type | |
|---|---|---|
| `code` | string | **Required.** The Python code to run. Top-level `await` works. |
| `environment` | string | What the code runs in: `"python"` (the default, just the standard library) or one of your [environments](#environments). In a [session](#sessions), it can be left out. |
| `timeout_secs` | integer | Stop the run after this many seconds. Defaults to, and is capped at, your org's limit. |
| `fuel_limit` | integer | Stop the run after this many [compute units](#compute-units). Defaults to, and is capped at, your org's limit. |

<!-- example: execute-request -->
```json
{"code": "import math\nprint(math.factorial(20))"}
```

### Response

A run that finishes returns `200`, even if the code failed. Check `status`.

| Field | Type | |
|---|---|---|
| `stdout` | string | What the code printed. Empty unless `status` is `success`. |
| `stderr` | string | What the code wrote to stderr. Empty unless `status` is `success`. |
| `compute_units` | integer | The [compute units](#compute-units) the run used. |
| `status` | string | How the run ended: see below. |
| `error` | string | Why the run didn't succeed. Only there when `status` isn't `success`. |
| `unsaved_variables` | array | [Sessions](#sessions) only: variables that couldn't be saved. Only there when there are some. |

<!-- example: execute-response -->
```json
{"stdout": "2432902008176640000\n", "stderr": "", "compute_units": 477034, "status": "success"}
```

| `status` | Meaning |
|---|---|
| `success` | The code ran to the end. |
| `error` | The code raised an exception. `error` has the traceback. |
| `timeout` | The run took longer than its time limit. |
| `resource_limit` | The run used up its compute units or memory. |

For example, `raise ValueError("bad input")` returns:

```json
{
  "stdout": "",
  "stderr": "",
  "compute_units": 1981661,
  "status": "error",
  "error": "Traceback (most recent call last):\n  ...\nValueError: bad input\n"
}
```

## Callbacks

Callbacks are functions that sandboxed code can `await`, and that your app runs.
Run code over the `/v1/stream` WebSocket, list the callbacks it may use, and
answer each call as it comes in:

```python
# This runs in the sandbox
user = await get_user(id=123)
await send_email(to=user["email"], subject="Hi")
```

### Connect

Open a WebSocket to `wss://api.dev.eryx.run/v1/stream` with the `Authorization` header set.
Browsers can't set headers on a WebSocket, so connect from your server, not
from a web page.

One connection runs one piece of code at a time. Wait for `completed` before
sending the next `execute`; any other message sent mid-run, apart from a
`callback_response`, is ignored. A connection can run many pieces of code, one
after another.

### Messages

Every message is a JSON object with a `type`.

**`execute`** (you send) starts a run. `code`, `timeout_secs` and `fuel_limit`
are the same as for [`POST /v1/execute`](#request), `callbacks` lists the
functions the code can call, and `session_id`, if set, runs the code in that
[session](#sessions):

```json
{
  "type": "execute",
  "code": "user = await get_user(id=123)\nprint(user['name'])",
  "callbacks": [{"name": "get_user", "description": "Look up a user by id"}]
}
```

**`callback`** (you receive) means the code called one of your callbacks.
`args` holds the keyword arguments it was called with:

```json
{"type": "callback", "call_id": "9f0c...", "name": "get_user", "args": {"id": 123}}
```

**`callback_response`** (you send) answers a call. Send its `call_id` back with
either a `result`, which can be any JSON and is what `await` returns in Python,
or an `error`, which raises a `RuntimeError` in Python:

```json
{"type": "callback_response", "call_id": "9f0c...", "result": {"name": "Ada", "email": "ada@example.com"}}
```

```json
{"type": "callback_response", "call_id": "9f0c...", "error": "No such user"}
```

**`completed`** (you receive) ends the run. It has the same fields as a
[`POST /v1/execute` response](#response):

```json
{"type": "completed", "stdout": "Ada\n", "stderr": "", "compute_units": 4297143, "status": "success"}
```

**`error`** (you receive) means the run didn't start, for example because a
[limit](#limits) was reached or the message wasn't valid. The connection stays
open:

```json
{"type": "error", "message": "Too many concurrent executions (limit: 5)"}
```

### Rules for callbacks

- **Use keyword arguments.** `await get_user(id=123)` sends `{"id": 123}`.
  Positional arguments raise a `TypeError` in Python, and you get no `callback`
  for that call: `await get_user(123)` fails with
  `get_user() takes 0 positional arguments but 1 was given`.
- Code can run callbacks concurrently, for example with `asyncio.gather`. You
  get a `callback` for each, and you can answer them in any order: `call_id` says
  which call an answer is for.
- Answer each call within 30 seconds, or it fails in Python.
- A run can make up to 100 callback calls.
- Calling a function that isn't in `callbacks` raises a `NameError`.
- If you disconnect mid-run, the run carries on until it finishes or hits a
  limit, and its compute units still count.

### Example client

A complete client in Python, using the
[`websockets`](https://pypi.org/project/websockets/) package:

```python
import asyncio
import json
import os

import websockets

CODE = """
user = await get_user(id=123)
print(f"Hello, {user['name']}")
"""

USERS = {123: {"name": "Ada"}}


async def handle(name, args):
    """Run a callback for the sandbox; raise to send it an error"""
    if name == "get_user":
        return USERS[args["id"]]
    raise ValueError(f"Unknown callback {name}")


async def main():
    headers = {"Authorization": f"Bearer {os.environ['ERYX_API_KEY']}"}
    async with websockets.connect("wss://api.dev.eryx.run/v1/stream", additional_headers=headers) as ws:
        await ws.send(json.dumps({
            "type": "execute",
            "code": CODE,
            "callbacks": [{"name": "get_user", "description": "Look up a user by id"}],
        }))
        async for raw in ws:
            msg = json.loads(raw)
            if msg["type"] == "callback":
                reply = {"type": "callback_response", "call_id": msg["call_id"]}
                try:
                    reply["result"] = await handle(msg["name"], msg["args"])
                except Exception as e:
                    reply["error"] = str(e)
                await ws.send(json.dumps(reply))
            elif msg["type"] == "completed":
                print(msg["status"], msg["stdout"], msg.get("error", ""))
                break
            elif msg["type"] == "error":
                raise RuntimeError(msg["message"])


asyncio.run(main())
```

## Sessions

A session keeps Python state between runs: variables, functions, classes and
imports from one run are there in the next, like cells in a notebook.

```sh
curl -X POST https://api.dev.eryx.run/v1/sessions -H "Authorization: Bearer $ERYX_API_KEY"
```

```json
{"id": "0b7f5c1e-...", "environment": "python", "ttl_secs": 3600, "expires_at": "2026-11-02T22:00:00Z"}
```

Then run code in it with `POST /v1/sessions/{id}/execute`, which takes the same
[request](#request) and gives the same [response](#response) as
`POST /v1/execute`:

```sh
curl -X POST https://api.dev.eryx.run/v1/sessions/$SESSION_ID/execute \
  -H "Authorization: Bearer $ERYX_API_KEY" -H "Content-Type: application/json" \
  -d '{"code": "x = 21"}'
curl -X POST https://api.dev.eryx.run/v1/sessions/$SESSION_ID/execute \
  -H "Authorization: Bearer $ERYX_API_KEY" -H "Content-Type: application/json" \
  -d '{"code": "print(x * 2)"}'
```

To use callbacks in a session, add its `session_id` to a WebSocket
[`execute`](#messages) message.

- **Environment.** A session runs in the `environment` it was created with
  (`{"environment": ...}`, default `"python"`). A run in it can leave
  `environment` out; naming a different one is a `422`.
- **Expiry.** A session expires `ttl_secs` after its last run: an hour, unless
  you create it with `{"ttl_secs": ...}` (at most 86400, a day). End one sooner
  with `DELETE /v1/sessions/{id}`.
- **One run at a time.** A run sent while another is going in the same session
  gets `409` with `session_busy`.
- **Failed runs.** If the code raises an exception, the changes it made before
  the exception are kept. A run that stops on a [limit](#limits) (`timeout` or
  `resource_limit`) keeps none of its changes.
- **What's kept.** State is saved between runs, so only values that can be
  saved are kept: most data, functions, classes and their instances, and
  imported modules. Open files, generators and other live objects aren't; the
  response lists them in `unsaved_variables`, and later runs won't see them.
  Saved state can be up to 1 MB. A run that leaves more keeps none of its
  changes and ends with `resource_limit`.
- **State that can't be restored.** If a session's saved state ever can't be
  loaded again, for example after we upgrade the Python runtime, every run in
  that session ends with `status: "error"` and an `error` starting
  `Failed to restore session state`. Delete the session and start a new one.

Runs in a session use [compute units](#compute-units) like any other run.
Saving and restoring the state is free.

## Environments

An environment is the sandbox plus Python packages you upload, such as
`.whl` files from PyPI. Run code in one by naming it in `environment`.

```sh
curl -X POST https://api.dev.eryx.run/v1/environments -H "Authorization: Bearer $ERYX_API_KEY" \
  -F name=templating -F imports=jinja2 \
  -F package=@jinja2-3.1.4-py3-none-any.whl -F package=@markupsafe-3.0.2-py3-none-any.whl
```

The upload is a multipart form:

| Field | |
|---|---|
| `name` | **Required.** Up to 63 lowercase letters, digits and dashes, unique in your org. `python` and names starting `python-` are taken. |
| `imports` | Modules to import when the environment is built, separated by spaces or commas, so runs don't spend time importing them. A module that fails to import fails the build. |
| `package` | A `.whl`, `.tar.gz` or `.tgz` file; repeat the field for each. Include every dependency: nothing is fetched for you. Up to 50 MB in all. A `package` field with an empty file name, as a browser sends for an empty file input, is skipped. |

It returns `202` and the environment in `"status": "building"`:

```json
{"id": "5c0e...", "name": "templating", "status": "building", "imports": ["jinja2"], "packages": ["jinja2-3.1.4-py3-none-any.whl", "markupsafe-3.0.2-py3-none-any.whl"], "created_at": "...", "updated_at": "..."}
```

Building takes a minute or so. `GET /v1/environments/{name}` shows its
`status`: `ready` once you can run code in it, or `failed` with an `error`
that says why (for example the traceback of an import that failed).
`GET /v1/environments` lists them all, and `DELETE /v1/environments/{name}`
deletes one.

```sh
curl -X POST https://api.dev.eryx.run/v1/execute -H "Authorization: Bearer $ERYX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"code": "from markupsafe import escape\nprint(escape(\"<b>hi</b>\"))", "environment": "templating"}'
```

Packages must be pure Python, or built for WebAssembly (`wasm32-wasi`):
wheels with native code for Linux or macOS won't load. Code in an environment
can't use the network or write files, as in any [sandbox](#the-sandbox).

While an environment is building or has failed, runs in it get `409` with
`environment_not_ready`. After we upgrade the sandbox, an environment is
rebuilt the next time you use it, which takes a minute; meanwhile its runs get
the same `409`.

## The sandbox

Code runs in CPython 3.14 with the standard library, including `asyncio`,
`json`, `re`, `datetime`, `decimal` and `sqlite3`. Each run starts fresh:
nothing is kept between runs, unless you use a [session](#sessions).

It can't:

- **Use the network.** Opening a socket raises `PermissionError`. Give code a
  callback for anything it needs to fetch.
- **Write files.** There's no writable filesystem.
- **Install packages.** Only the standard library is available, so
  `import numpy` raises `ModuleNotFoundError`, unless you upload them to an
  [environment](#environments).
- **Use more than 64 MB of memory.** Past that, Python raises `MemoryError`.

## Limits

Your org's limits are on the [dashboard](https://app.dev.eryx.run). They apply across
all of the org's keys:

| Limit | What happens when you reach it |
|---|---|
| Requests per minute | `429` with `rate_limit_exceeded`. Each API request, each WebSocket connection and each WebSocket `execute` counts as one. |
| Concurrent executions | `429` with `concurrency_limit`, straight away: requests don't queue. |
| Compute units per run | The run stops with `status: "resource_limit"`. |
| Time per run | The run stops with `status: "timeout"`. |
| Sessions | `429` with `session_limit` when creating a 51st unexpired [session](#sessions). |
| Environments | `429` with `environment_limit` when creating an 11th [environment](#environments). |

A request can set a lower `fuel_limit` or `timeout_secs` for itself, but not a
higher one. A `429` for the rate limit has a `Retry-After` header with the
number of seconds to wait.

Requests without a valid key are rate limited per IP address instead.

## Compute units

A compute unit is one WebAssembly instruction. The same code always uses the
same number of them, so costs are predictable: `print(1 + 1)` uses about 370,000
and a typical small script a few million. Time spent waiting for a callback
uses none.

`compute_units` counts runs that fail as well as runs that succeed. For a run
that times out it's accurate to within a few million.

## Errors

When a request is rejected, the response has an error status and this body:

<!-- example: error-missing-auth -->
```json
{"error": {"code": "missing_auth", "message": "Missing Authorization header"}}
```

| Status | `code` | Meaning |
|---|---|---|
| `400`, `415`, `422` | `invalid_request` | The body isn't JSON, isn't sent as `application/json`, or is missing `code`. |
| `401` | `missing_auth` | No `Authorization` header. |
| `401` | `invalid_auth` | The header doesn't use the `Bearer` scheme. |
| `401` | `invalid_key` | The key is wrong or has been revoked. |
| `404` | `not_found` | No such [session](#sessions) in your org (or it has expired), or no such [environment](#environments). |
| `409` | `conflict` | You already have an environment with this name. |
| `409` | `environment_not_ready` | The environment is building, failed to build, or is being rebuilt. |
| `413` | `invalid_request` | An environment's packages are over 50 MB (`Packages can be up to 50 MB in all`). |
| `409` | `session_busy` | Another run is going in this session. |
| `429` | `rate_limit_exceeded` | Over your org's requests per minute. Wait for `Retry-After` seconds. |
| `429` | `concurrency_limit` | Your org already has its maximum number of runs going. |
| `429` | `session_limit` | Your org already has 50 unexpired sessions. |
| `429` | `environment_limit` | Your org already has 10 environments. |
| `500` | `internal_error` | Something went wrong on our side. |
| `503` | `unavailable` | We're at capacity. Retry after a short wait. |

Code that fails isn't an error response: it's a `200` with a `status` that
says what happened (see [Response](#response)).
