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/executeruns some code and returns its output. Use it when the code doesn't need to call you back./v1/streamis a WebSocket. Use it when the code calls functions (callbacks) that your app handles while the code runs.
The base URL is https://app.eryx.run. Requests and responses are JSON.
Quick start
- Sign in with GitHub and create an API key.
- Run some code:
export ERYX_API_KEY=<your key>
curl https://app.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
| 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. In a session, 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. 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.
| 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 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 only: variables that couldn't be saved. Only there when there are some. |
{"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:
{
"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://app.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 aTypeErrorin Python, and you get nocallbackfor that call:await get_user(123)fails withget_user() takes 0 positional arguments but 1 was given. - Code can run callbacks concurrently, for example with
asyncio.gather. You get acallbackfor each, and you can answer them in any order:call_idsays 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
callbacksraises aNameError. - 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://app.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://app.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://app.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://app.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
environmentit was created with ({"environment": ...}, default"python"). A run in it can leaveenvironmentout; naming a different one is a422. - Expiry. A session expires
ttl_secsafter its last run: an hour, unless you create it with{"ttl_secs": ...}(at most 86400, a day). End one sooner withDELETE /v1/sessions/{id}. - One run at a time. A run sent while another is going in the same session
gets
409withsession_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 (
timeoutorresource_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 withresource_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 anerrorstartingFailed 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://app.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":
{"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://app.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 numpyraisesModuleNotFoundError, 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:
| 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. |
| Environments | 429 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"}}
| 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 in your org (or it has expired), or no such environment. |
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).