Skip to content
Registry

code-runner

v0.1.12

Run untrusted Node.js and Python in-process — V8 isolates and CPython-on-WebAssembly behind one run/register_function/teardown API, with no microVM and no /dev/kvm. Code gets a global `iii` and a private scratch directory.

license: Apache-2.0javascriptpythonrunsandboxv8wasm

Install

bash
iii trigger compose::add worker=code-runner@0.1.12

Run in your project with the iii engine running. Exact versions are immutable and digest-pinned.

Agent-ready brief for v0.1.12
Install, config, dependencies, readme, skills and API reference, all in one place. Fetch as agent-context.md for an LLM to consume.
The same content rendered as discrete blocks below is exposed as a single markdown document at /workers/code-runner.md?version=0.1.12. Paste it into an LLM prompt or pipe it through curl from a worker.

Install

iii trigger compose::add worker=code-runner@0.1.12

Dependencies

Dependency · 1Version

Readme

README.md

code-runner

Run untrusted Node.js and Python in-process: iterate with code-runner::run, publish working functions to the bus with code-runner::register_function, clean up with code-runner::teardown.

Same wire contract as sandbox-code-runner, so a caller written against that worker needs no changes — but no microVM, no daemon, and no /dev/kvm. JavaScript runs in a V8 isolate; Python runs as CPython compiled to WebAssembly inside wasmtime.

iii trigger compose::add worker=code-runner

Quickstart

iii trigger code-runner::run lang=node code='console.log("hi"); return 2 + 2'
{ "stdout": "hi\n", "stderr": "", "exit_code": 0, "success": true,
  "duration_ms": 12, "result": 4 }

run is one-shot by default: it creates a runtime, runs your code, returns, and destroys it. Nothing persists, and the response carries no runtime_id because there is nothing left to address.

Pass keep: true to leave it running. The response's runtime_id then addresses it on later calls — same globals, same scratch directory — and is the capability teardown needs.

A failing script is a response, not an error. stdout, stderr and exit_code come back verbatim; a thrown exception is exit_code: 1 with the message on stderr, not a failed bus call. Errors are reserved for infrastructure: timeouts, resource kills, unknown runtimes.

result is this worker's one addition to sandbox-code-runner's response: the value your code returned, so a caller does not have to print JSON and parse stdout.

Output caps

result, stdout and stderr are each capped before the response leaves this worker, so one runaway script cannot flood your context window with its own output. Both caps are on by default and apply the same way to node and python, and to a thrown/raised response exactly as to a successful one — exit_code, success, duration_ms and runtime_id are never touched: a capped response is still success: true when the run itself succeeded, only the echo is bounded.

result — capped at max_result_bytes (default 32768 bytes, 0 disables it). An oversized value is not truncated or reshaped, it is replaced whole by a string marker naming what was dropped:

<omitted: result was ~{KB} KB ({N array elements | N object keys | a N-char
string}); returning it whole would flood the model context — print a slice,
aggregate in code, or write it to iii.files and return a summary>

For example, for (i=0;i<100000;i++) result.push(i); return result returns result as <omitted: result was ~575 KB (100000 array elements); ...> instead of the 100,000-element array. This is deliberate: result is often consumed by code (an fp::pipe step, another iii.trigger call), so a step that receives a string where it expected an array fails loudly with the marker text in the error — the teaching failure you want, not data quietly gone missing.

stdout / stderr — each capped independently at max_stream_bytes (default 16384 bytes, 0 disables it). An oversized stream keeps its first 60% and its last 40% — so both the first failure and the final summary line survive — with a marker spliced into the middle:

[head: ~60% of the budget]
[…stdout truncated: was ~{KB} KB; middle omitted]
[tail: ~40% of the budget]

(stderr's marker names itself the same way.)

If you hit a cap: the full value is gone at this layer, not stored anywhere for a later look — re-running is the only way to get it back. Print a slice instead of the whole value, aggregate in code (counts, sums, first/last N) instead of returning or logging everything, or write the full data to iii.files and return a short summary.

The iii global

Code you run gets a global iii:

  • await iii.trigger({ function_id, payload }) — invoke any bus function.
  • iii.registerFunction(id, handler, opts?) — publish one for the life of this runtime. For one that outlives it, use code-runner::register_function. opts takes description, and request_format / response_format — JSON Schema objects engine::functions::info shows callers in place of "any".
  • iii.files — a private scratch directory that lives exactly as long as the runtime: write(name, contents), read(name), readText(name), list(), remove(name). Names are one file each — no paths, no subdirectories — and the directory is bounded by scratch_mb/scratch_files.
  • iii.namespace — the prefix this runtime may register under.

Python's iii is narrower: iii.trigger({...}) and iii.namespace, and it is synchronous — the call blocks until the answer comes back. Registration from inside guest Python is not available; use code-runner::register_function (node) or trigger a worker that registers for you. A guest timeout is clamped to what is left of the run's own budget, so iii.trigger inside a 5s run cannot ask for 30s and take the whole run down with it.

answer = iii.trigger({"function_id": "state::get", "payload": {"key": "k"}})
result = answer["value"]

What this cannot do

The in-process Node and Python bus bridges refuse state::claim-namespace, harness::state::*, provider-openai-codex::state::*, and provider::openai-codex::{login,auth}::*. The checks apply before dispatch, including queued/fire-and-forget calls and trigger-registration targets. Trigger bindings cannot target another trigger registration, which would otherwise install an unchecked second binding. Ordinary bus calls keep their existing behavior and authority; these checks are not a general sandbox for arbitrary code or for other workers' capabilities.

sandbox-code-runner has a different boundary: its microVM guests connect directly to the engine using the real SDK. They do not pass through these host bridges. A check on that runner's generated iii global could restrict normal helper calls, but guests could bypass it by importing the SDK or opening a raw engine connection. Such connections remain trusted under the existing engine model; this change does not claim to restrict them.

Read this before choosing between the two workers.

code-runner sandbox-code-runner
Isolation V8 isolate / wasm sandbox, in-process microVM per runtime
Requires /dev/kvm or Apple Silicon no yes
Outbound network no yes
npm install / pip install no yes
Real filesystem no — only iii.files yes, a whole OS
Guest iii host-implemented surface the real iii-sdk client
Per-call cost milliseconds VM boot

There is no network, so package installation cannot work at all — not slowly, not with a flag. If your code needs a third-party package, use sandbox-code-runner. What you can do here is write your own modules into iii.files and load them from there.

Configuration

Served by the configuration worker (entry id code-runner) — edit it in the Console's global Settings modal (the worker ships its own form) or via configuration::set; the fs adapter persists it under ./data/configuration/code-runner.yaml. There is no committed config file; --config <path> seeds the initial value on first boot only. Reload tier 1: max_result_bytes, max_stream_bytes, default_timeout_ms, max_timeout_ms, and inject_guidance hot-apply on save; the remaining fields are captured when the engines boot and cannot be rebound under live guest runtimes — a change to them is saved immediately and applies at the next worker restart (the reload log says so).

key default meaning
max_runtimes 32 live runtimes across both engines
default_timeout_ms 5000 per run when unspecified
max_timeout_ms 30000 ceiling a request's timeout_ms is clamped to
idle_ttl_secs 900 reap runtimes idle this long
heap_mb 128 V8 object-heap cap per node runtime
external_mb 64 off-heap cap per node runtime; heap_mb does not cover it
scratch_mb 8 iii.files quota per node runtime; 0 disables it entirely. Python's /work budget is fixed by its engine (256 MiB, 20k files)
scratch_files 64 max files per node runtime
scratch_root unset where node scratch directories live
inject_guidance true append the code-runner usage guidance to agent system prompts (the pre_generate hook); off answers the hook with a no-op
max_result_bytes 32768 ceiling on the serialized result; over it becomes an omission marker (see Output caps). 0 disables it
max_stream_bytes 16384 ceiling on each of stdout/stderr; over it keeps head+tail around a truncation marker. 0 disables it

Worst-case host footprint is max_runtimes * scratch_mb — 256 MiB at the defaults — and the system temp directory is tmpfs, i.e. host RAM, on most Linux hosts. Set scratch_root to real disk if that matters.

Errors

code meaning
code-runner::invalid_request malformed request, or a lang that contradicts the runtime
code-runner::runtime_not_found unknown runtime_id, or a namespace with nothing behind it
code-runner::expired the runtime was reaped or killed; get a fresh one
code-runner::capacity all runtime slots in use — retry later
code-runner::timeout the run blew its deadline
code-runner::resource_exhausted a cap was hit mid-run (memory, scratch quota). Unlike capacity, retrying will not help — shrink the workload
code-runner::handler_error a registered handler threw
code-runner::engine infrastructure failure

Console UI

The worker ships an injected console UI (the injectable-UI protocol, iii/tech-specs/2026-07-17-injectable-ui) — two assets, built from ui/ by esbuild and embedded in the binary, so there is nothing to install and nothing to serve separately:

Asset Slot What it does
code-runner/page.js console:script how run / register_function / teardown render in chat and the traces span view
code-runner/styles.css console:style the stylesheet, every rule scoped under [data-iii-ui="code-runner"]

One renderer per op, under ui/src/function-trigger-message/, sharing the card frame in ui/src/lib/shared.tsx. They replace the console's raw-JSON card, which turns code into one escaped line and buries the verdict.

What the cards are for:

  • run leads with the verdict (exit, duration), then the completion value, then the source highlighted as the language it runs as, then stdout/stderr as terminal output. A non-zero exit renders as a warning, not an alert — errors are for infrastructure, and a script that throws is an ordinary response.
  • A null result is shown explicitly, with the engine's return convention beside it: node code is a function body (return 2 + 2), python code is a module (assign result). That mismatch is the usual reason a call "worked" and returned nothing.
  • register_function puts the namespace the id claims on its own line, and advises when the source defines no handler — on python that fails the registration outright, not the first call.
  • teardown answers the one question the payload does not: which function ids stopped resolving.

runtime_id is never rendered in full. It is a capability — whoever holds one can run into or tear down that runtime — so it appears only as a truncated chip whose full value is handed over on an explicit click-to-copy. Every other string on a card is filtered first: stdout, stderr, error messages (which quote the id by design — see src/error.rs), the submitted source, the completion value, and the raw json tab the console mounts regardless of what a card does.

Developing it

cd ui && pnpm install && pnpm build   # cargo build does this for you
pnpm test                             # vitest, renders the cards server-side

build.rs runs pnpm install && pnpm build when ui/dist/ is missing or stale, so a plain cargo build is enough. SKIP_UI_BUILD=1 uses the existing ui/dist/ as-is. For a hot-reload loop, run pnpm watch and start the worker with III_CODE_RUNNER_UI_WATCH=1 — every open console tab swaps the changed asset.

Status

node python
one-shot run yes yes
keep / runtime_id yes yes
what a kept runtime persists globals and files globals and files
register_function yes yes
iii.trigger from guest code yes yes
iii.registerFunction from guest code yes no
iii.files yes use /work

A kept python runtime persists its working directory and its interpreter. python.wasm is a WASI command module — the artifact exports memory and _start and nothing else — so the interpreter cannot be re-entered once _start returns. Instead it never returns: the wrapper parks on stdin between calls and the host hands it one turn at a time. Globals stay bound, modules stay imported, and files under /work stay put.

That is a superset of what sandbox-code-runner promises for its own keep, which is "same filesystem, fresh interpreter process each time". Code written against that worker still behaves correctly here; code written against this one may not port back.

Two consequences worth knowing before you rely on it:

  • A call that overruns its timeout_ms takes the interpreter with it. The only kill that reaches a guest parked in a host call unwinds _start, so there is nothing left to resume. The working directory survives, and the next call on that runtime_id boots a fresh interpreter on the same files.
  • memory_mb is fixed when the runtime is created. Wasm linear memory only ever grows, so a per-call ceiling would be a promise the sandbox cannot keep — a later caller would inherit whatever an earlier one grew to. Sending memory_mb alongside a runtime_id is refused rather than ignored.

Only result is cleared between turns. It is the output slot, not state: a turn that assigns no result returns null, never the previous turn's value.

register_function with lang: "python"

Your source must define handler(payload); anything else it defines stays available to every later invocation, because the namespace runs on one pinned interpreter.

# code-runner::register_function, lang=python, function_id="my-app::greet"
import json

_greetings = {"en": "hello", "pt": "olá"}

def handler(payload):
    return _greetings.get(payload.get("lang", "en"), "hello")

Four things worth knowing:

  • Source that leaves no callable handler fails the registration, not the first call. The id never reaches the catalog, so a corrected redeploy of the same id works.
  • Re-registering an id replaces it. The previous registration is retired before the new one is published.
  • The interpreter is exempt from the idle sweep. A registration's lifetime is the registration's, not its traffic's; teardown with namespace is the only way to remove one.
  • The namespace is shared with node. Function ids are claimed in one registry, so my-app::greet cannot exist in both languages, and the same charset rule applies to the namespace in both.
  • Schemas are optional but shown. request_format / response_format (JSON Schema objects, max 16 KiB, must actually constrain something) ride the registration in either language, and engine::functions::info shows them in place of "any".

Unlike node, guest Python cannot register a function from inside a run — python.wasm exports _start and nothing else, so there is no iii.registerFunction to call. The host publishes on the guest's behalf and dispatches each invocation as one turn on the namespace interpreter.

# lang=python, keep=true — then reuse the runtime_id
open("/work/state.json", "w").write('{"n": 1}')
result = "saved"

API reference (JSON)

agent-api-reference.json
{
  "functions": [
    {
      "description": "Internal pre_generate hook: appends code-runner usage guidance to the agent system prompt. Bound to harness::hook::pre-generate at worker startup; not called directly. Silenced by inject_guidance: false in the code-runner configuration.",
      "metadata": {
        "internal": true
      },
      "name": "code-runner::inject-guidance",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "GenerateContext": {
            "properties": {
              "system_prompt": {
                "default": "",
                "description": "The system prompt assembled so far (base + any prior hook's mutation).",
                "type": "string"
              }
            },
            "type": "object"
          }
        },
        "description": "The slice of the `pre_generate` hook envelope this hook reads; other fields are ignored.",
        "properties": {
          "generate": {
            "$ref": "#/definitions/GenerateContext"
          }
        },
        "title": "PreGenerateEvent",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "PreGenerateMutations": {
            "description": "The harness applies `system_prompt` only when the key is present, so `None` serializes to an empty object: the safe no-op that preserves the harness's assembled prompt.",
            "properties": {
              "system_prompt": {
                "description": "Full replacement system prompt (base + appended guidance). The harness overwrites, it does not merge.",
                "type": [
                  "string",
                  "null"
                ]
              }
            },
            "type": "object"
          }
        },
        "description": "Hook envelope returned to the harness: the mutations to apply to the generation.",
        "properties": {
          "mutations": {
            "$ref": "#/definitions/PreGenerateMutations"
          }
        },
        "required": [
          "mutations"
        ],
        "title": "PreGenerateResponse",
        "type": "object"
      }
    },
    {
      "description": "Internal: reload code-runner settings from the authoritative configuration on change. Output caps and timeouts hot-apply; runtime/memory/scratch limits apply at the next worker restart.",
      "metadata": {
        "internal": true
      },
      "name": "code-runner::on-config-change",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "title": "OnConfigChangeRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "ok": {
            "type": "boolean"
          }
        },
        "required": [
          "ok"
        ],
        "title": "OnConfigChangeResponse",
        "type": "object"
      }
    },
    {
      "description": "Publish a bus function whose handler runs in a sandbox; no runtime_id needed. source must define handler(payload) in lang; each call runs it and returns its JSON result. The namespace (function_id before `::`) shares one runtime per lang; its functions stop resolving once torn down or idle-reaped.",
      "metadata": {},
      "name": "code-runner::register_function",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "Lang": {
            "description": "Which engine backs a runtime.\n\nOnly `node` and `python` are accepted; anything else fails deserialization before a handler runs, so the schema itself is the validation.",
            "enum": [
              "node",
              "python"
            ],
            "type": "string"
          }
        },
        "properties": {
          "description": {
            "default": null,
            "description": "What `engine::functions::info` shows a caller — write one.",
            "type": [
              "string",
              "null"
            ]
          },
          "function_id": {
            "description": "Bus id, e.g. `my-app::greet`; the segment before the first `::` is the namespace, which holds one persistent runtime per lang.",
            "type": "string"
          },
          "lang": {
            "allOf": [
              {
                "$ref": "#/definitions/Lang"
              }
            ],
            "description": "Language of the namespace runtime this handler runs in."
          },
          "request_format": {
            "default": null,
            "description": "Optional JSON Schema object (≤ 16 KiB, must constrain: `type`/`properties`/`$ref`) for the payload; shown by `engine::functions::info`, not enforced."
          },
          "response_format": {
            "default": null,
            "description": "Optional JSON Schema for the handler's return value; same rules as `request_format`."
          },
          "source": {
            "description": "Source that defines `handler(payload)` in `lang`: `export function handler(payload) {…}` (node) or `def handler(payload): …` (python).",
            "type": "string"
          }
        },
        "required": [
          "function_id",
          "lang",
          "source"
        ],
        "title": "RegisterRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "function_id": {
            "type": "string"
          },
          "registered": {
            "type": "boolean"
          }
        },
        "required": [
          "function_id",
          "registered"
        ],
        "title": "RegisterResponse",
        "type": "object"
      }
    },
    {
      "description": "Run code in a sandbox (no network, host filesystem or packages). One-shot by default: nothing persists and no runtime_id is returned. keep: true keeps the runtime and returns its runtime_id to reuse until code-runner::teardown or the idle TTL reaps it. A failing script is a response, not an error.",
      "metadata": {},
      "name": "code-runner::run",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "Lang": {
            "description": "Which engine backs a runtime.\n\nOnly `node` and `python` are accepted; anything else fails deserialization before a handler runs, so the schema itself is the validation.",
            "enum": [
              "node",
              "python"
            ],
            "type": "string"
          }
        },
        "properties": {
          "code": {
            "description": "Node: an async function body (`return x` sets `result`; `await` works). Python: a module (assign `result = x`). A global `iii` is in scope.",
            "type": "string"
          },
          "keep": {
            "default": false,
            "description": "When `runtime_id` is omitted: `true` leaves the runtime running and returns its `runtime_id`; `false` destroys it after the run.",
            "type": "boolean"
          },
          "lang": {
            "anyOf": [
              {
                "$ref": "#/definitions/Lang"
              },
              {
                "type": "null"
              }
            ],
            "default": null,
            "description": "Required when `runtime_id` is omitted; on an existing runtime omit it or pass the runtime's own language."
          },
          "runtime_id": {
            "default": null,
            "description": "Run in this existing runtime (from a keep: true run), sharing its globals and scratch; it is not destroyed afterwards. Omit to run one-shot.",
            "type": [
              "string",
              "null"
            ]
          },
          "timeout_ms": {
            "default": null,
            "description": "Wall-clock budget in milliseconds, clamped to the configured maximum.",
            "format": "uint64",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          }
        },
        "required": [
          "code"
        ],
        "title": "RunRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "duration_ms": {
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "exit_code": {
            "format": "int64",
            "type": "integer"
          },
          "result": {
            "description": "The completion value: what the code returned (node) or assigned to `result` (python). Null when it returned nothing, when it returned something JSON cannot represent, or when the run failed.\n\nAlways present, never skipped — unlike `runtime_id`, a null result is information (\"the code returned nothing\") rather than an absence."
          },
          "runtime_id": {
            "description": "Present when this run addresses a runtime that outlives the call — a `keep: true` run that SUCCEEDED, or a `runtime_id` echoed back. Absent, not null, otherwise: there is nothing to address. Treat a present value as a secret.",
            "type": [
              "string",
              "null"
            ]
          },
          "stderr": {
            "type": "string"
          },
          "stdout": {
            "type": "string"
          },
          "success": {
            "type": "boolean"
          }
        },
        "required": [
          "duration_ms",
          "exit_code",
          "result",
          "stderr",
          "stdout",
          "success"
        ],
        "title": "RunResponse",
        "type": "object"
      }
    },
    {
      "description": "Destroy a runtime: unregister its bus functions, kill it and free its slot. Pass exactly one of runtime_id (from a keep: true run) or namespace (a register_function namespace, e.g. \"app\" for app::greet).",
      "metadata": {},
      "name": "code-runner::teardown",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Exactly one of `runtime_id` or `namespace` must be set.",
        "properties": {
          "namespace": {
            "default": null,
            "type": [
              "string",
              "null"
            ]
          },
          "runtime_id": {
            "default": null,
            "type": [
              "string",
              "null"
            ]
          }
        },
        "title": "TeardownRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "namespace": {
            "description": "Set when addressed by `namespace` — echoed in its canonical `app::` form, since more than one runtime (one per language) can back it and there is no single `runtime_id` to report.",
            "type": [
              "string",
              "null"
            ]
          },
          "runtime_id": {
            "description": "Set when this teardown was addressed by `runtime_id`.",
            "type": [
              "string",
              "null"
            ]
          },
          "torn_down": {
            "type": "boolean"
          },
          "unregistered": {
            "description": "Bus function ids this teardown unregistered, across every runtime it destroyed.",
            "items": {
              "type": "string"
            },
            "type": "array"
          }
        },
        "required": [
          "torn_down",
          "unregistered"
        ],
        "title": "TeardownResponse",
        "type": "object"
      }
    },
    {
      "description": "Serve the code-runner worker's injected console UI assets (content function for its console:script / console:style triggers).",
      "metadata": {
        "internal": true
      },
      "name": "code-runner::ui-content",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Input of the content function: the console asks for one asset by path.",
        "properties": {
          "path": {
            "description": "The asset path from the trigger config (e.g. `state/page.js`).",
            "type": "string"
          }
        },
        "required": [
          "path"
        ],
        "title": "UiContentInput",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Output of the content function.",
        "properties": {
          "content": {
            "description": "The asset source, verbatim.",
            "type": "string"
          },
          "content_type": {
            "description": "MIME type the console should serve the asset with.",
            "type": "string"
          }
        },
        "required": [
          "content",
          "content_type"
        ],
        "title": "UiContentResult",
        "type": "object"
      }
    }
  ],
  "triggers": []
}

Search the registry

Find workers, kits and executables, or jump to a page.