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 with GitHub and create an API key.
  2. Run some code:
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)"}'
{"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:

Authorization: Bearer eryx_...

Create and revoke keys in the dashboard. 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 and usage: every key in an org shares them.

Run code

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

Request

FieldType
codestringRequired. The Python code to run. Top-level await works.
environmentstringWhat the code runs in: "python" (the default, just the standard library) or one of your environments. In a session, it can be left out.
timeout_secsintegerStop the run after this many seconds. Defaults to, and is capped at, your org's limit.
fuel_limitintegerStop the run after this many compute units. Defaults to, and is capped at, your org's limit.
{"code": "import math\nprint(math.factorial(20))"}

Response

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

FieldType
stdoutstringWhat the code printed. Empty unless status is success.
stderrstringWhat the code wrote to stderr. Empty unless status is success.
compute_unitsintegerThe compute units the run used.
statusstringHow the run ended: see below.
errorstringWhy the run didn't succeed. Only there when status isn't success.
unsaved_variablesarraySessions only: variables that couldn't be saved. Only there when there are some.
{"stdout": "2432902008176640000\n", "stderr": "", "compute_units": 477034, "status": "success"}
statusMeaning
successThe code ran to the end.
errorThe code raised an exception. error has the traceback.
timeoutThe run took longer than its time limit.
resource_limitThe run used up its compute units or memory.

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

{
  "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:

# 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, callbacks lists the functions the code can call, and session_id, if set, runs the code in that session:

{
  "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:

{"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:

{"type": "callback_response", "call_id": "9f0c...", "result": {"name": "Ada", "email": "ada@example.com"}}
{"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:

{"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 was reached or the message wasn't valid. The connection stays open:

{"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 package:

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.

curl -X POST https://api.dev.eryx.run/v1/sessions -H "Authorization: Bearer $ERYX_API_KEY"
{"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 and gives the same response as POST /v1/execute:

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 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 (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 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.

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
nameRequired. Up to 63 lowercase letters, digits and dashes, unique in your org. python and names starting python- are taken.
importsModules 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.
packageA .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":

{"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.

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.

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.

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.
  • Use more than 64 MB of memory. Past that, Python raises MemoryError.

Limits

Your org's limits are on the dashboard. They apply across all of the org's keys:

LimitWhat happens when you reach it
Requests per minute429 with rate_limit_exceeded. Each API request, each WebSocket connection and each WebSocket execute counts as one.
Concurrent executions429 with concurrency_limit, straight away: requests don't queue.
Compute units per runThe run stops with status: "resource_limit".
Time per runThe run stops with status: "timeout".
Sessions429 with session_limit when creating a 51st unexpired session.
Environments429 with environment_limit when creating an 11th environment.

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:

{"error": {"code": "missing_auth", "message": "Missing Authorization header"}}
StatuscodeMeaning
400, 415, 422invalid_requestThe body isn't JSON, isn't sent as application/json, or is missing code.
401missing_authNo Authorization header.
401invalid_authThe header doesn't use the Bearer scheme.
401invalid_keyThe key is wrong or has been revoked.
404not_foundNo such session in your org (or it has expired), or no such environment.
409conflictYou already have an environment with this name.
409environment_not_readyThe environment is building, failed to build, or is being rebuilt.
413invalid_requestAn environment's packages are over 50 MB (Packages can be up to 50 MB in all).
409session_busyAnother run is going in this session.
429rate_limit_exceededOver your org's requests per minute. Wait for Retry-After seconds.
429concurrency_limitYour org already has its maximum number of runs going.
429session_limitYour org already has 50 unexpired sessions.
429environment_limitYour org already has 10 environments.
500internal_errorSomething went wrong on our side.
503unavailableWe'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).