Elixir NIF wrapper for zapcode, a minimal secure TypeScript-subset interpreter written in Rust.
ExZapcode is the TypeScript sibling of ExMonty (Python). Both embed a small, sandboxed interpreter as a NIF and expose the same interactive start / resume model — designed for running AI-agent-generated code that calls back into your application.
- Microsecond startup — a bytecode VM, no Node/V8 process
- Interactive execution — guest code pauses at external function calls, hands control to Elixir, and resumes with the result
- Resource limits — cap wall-clock time, memory, stack depth, and allocations
- Language-level sandbox — no filesystem, network, env,
eval,import, orrequire; the only way guest code reaches the host is through the external functions you register
def deps do
[{:ex_zapcode, "~> 0.1"}]
endPrecompiled NIFs are published for aarch64/x86_64 macOS and Linux, so most
users need no Rust toolchain. To force a local build, set EXZAPCODE_BUILD=1
(requires a stable Rust toolchain).
# Evaluate an expression — the last expression is the result
{:ok, 7, ""} = ExZapcode.eval("1 + 2 * 3")
# Bind inputs as globals
{:ok, "hi Sam", ""} = ExZapcode.eval("`hi ${name}`", inputs: %{"name" => "Sam"})Register host functions the guest may await. Execution suspends at each call,
you run the tool in ordinary Elixir (DB access, HTTP, whatever), and it resumes
with the return value:
{:ok, 3, ""} =
ExZapcode.Sandbox.run(
"""
const a = await execute_sql({ statement: "SELECT 1 as n" });
const b = await execute_sql({ statement: "SELECT 2 as n" });
a.rows[0].n + b.rows[0].n
""",
functions: %{
"execute_sql" => fn [%{"statement" => sql}] ->
{:ok, run_query(sql)} # -> %{"rows" => [...]}
end
}
)A handler is fn args -> {:ok, value} | {:error, type, message} (or
fn args, kwargs for ExMonty parity — kwargs is always %{} since
TypeScript calls are positional). By convention a tool is called with a single
options object, so args is typically [opts_map].
The suspend/resume design means the tool runs on a normal BEAM process, not inside the NIF — so it can safely do database and network I/O with no reentrancy concerns.
Sandbox.run/2 drives the loop for you. If you need manual control:
{:function_call, "getWeather", [city], snapshot, _out} =
ExZapcode.start("await getWeather(city)",
inputs: %{"city" => "London"}, functions: ["getWeather"])
{:complete, %{"temp" => 18}, _out} =
ExZapcode.resume(snapshot, %{"temp" => 18})A suspended run can be serialized to a binary, persisted anywhere (a DB row, a queue, another node), and resumed later — even in a different process or after a restart. This is the durable-execution path: pause at a tool call, run the tool whenever (a slow job, a human approval, an external webhook), then resume.
# 1. Run until it awaits a tool
{:function_call, "getWeather", ["Paris"], snap, _} =
ExZapcode.start("const w = await getWeather(city); w.temp * 2",
functions: ["getWeather"], inputs: %{"city" => "Paris"})
# 2. Freeze the paused computation to bytes and store it
{:ok, bytes} = ExZapcode.dump_snapshot(snap)
# ...persist `bytes`, do the work out-of-band...
# 3. Later / elsewhere: thaw and resume with the result
{:ok, restored} = ExZapcode.load_snapshot(bytes)
{:complete, 44, _} = ExZapcode.resume(restored, %{"temp" => 22})dump_snapshot/1 is non-destructive — the original in-memory snapshot stays
resumable after a dump. The snapshot is self-contained (it carries the compiled
program), so load_snapshot/1 needs nothing else.
load_snapshot/1deserializes directly into native VM state. Only load bytes your application produced and stored somewhere trusted.
| TypeScript | Elixir |
|---|---|
number (integer) |
integer |
number (float) |
float |
string |
String.t |
boolean |
true / false |
null / undefined |
nil |
| array | list |
| object | map (string keys) |
Objects and arrays passed in (inputs, tool return values) are converted the same way in reverse. Elixir map keys become object keys as strings.
Every public function reports failures as {:error, %ExZapcode.Exception{type:, message:}},
where type is one of :parse_error, :compile_error, :runtime_error,
:type_error, :reference_error, :unknown_external_function, :memory_limit,
:timeout, :stack_overflow, :allocation_limit, :snapshot_error, or
:sandbox_violation.
zapcode implements a subset of TypeScript (types are stripped by
oxc; a bytecode VM runs the result). It is
sized for agent glue code — data shaping and tool orchestration — not for running
npm libraries. It runs against our fork of zapcode-core
(jtippett/zapcode), which carries
correctness fixes not yet upstream (see below).
Works well: const/let, arrow & named functions, recursion, template
literals, ternary, for…of, arrow .map/.filter/.reduce, array & object
destructuring (incl. destructuring parameters — .map(([k, v]) => …)),
array & object spread ([...a, x], {...o}), in-place array mutation
(push/pop/shift/unshift/splice/reverse/fill), switch,
optional chaining (?.), nullish coalescing (??), JSON.stringify/JSON.parse,
common String methods (incl. replace with a string argument),
Object.keys/values/entries, Math, class, typeof, try/catch, and
async callbacks that suspend on external calls
(e.g. cities.map(async c => await getWeather(c))).
Fixed in our fork (were broken/silently-wrong in upstream v1.5.3):
array & object spread, in-place array mutation, switch (a bare break looped
forever), destructuring parameters, and regex (see below).
Known gaps — all now fail loudly (no silent wrong answers); verify against your workload and re-check on upgrade:
| Construct | Behavior |
|---|---|
Regular expressions (/re/) |
rejected at parse time with a clear error (use string methods) |
Number.prototype methods (toFixed, …) |
unsupported → type_error |
Date |
unsupported (deliberate — no clock in the sandbox) → type_error |
await inside a for…of loop body |
not yet snapshot-serializable → snapshot_error |
The design goal is no silent wrong answers: unsupported constructs raise a
tagged ExZapcode.Exception rather than returning a plausible-but-wrong value.
The gap table is re-characterized on every upstream bump (see
UPDATE_PROCEDURE.md).
The public surface intentionally mirrors ExMonty: Sandbox.run/2 returns the
same {:ok, value, output} | {:error, %Exception{}} shape, and %ExZapcode.Exception{}
mirrors %ExMonty.Exception{}. Code that dispatches to a Python runtime can add a
TypeScript one as a near-copy.
| ExMonty (Python) | ExZapcode (TypeScript) | |
|---|---|---|
| Interactive model | start/resume |
start/resume (same) |
| Suspension reasons | function call, method call, OS call, name lookup, futures, complete | function call, complete (one reason — simpler) |
| Tool-call args | positional + kwargs (Python) | positional only; call tools with an options object |
| Snapshot serialization | dump_snapshot/load_snapshot — consumes the snapshot |
dump_snapshot/load_snapshot — non-destructive (still resumable) |
| Reusable compiled runner | dump_runner/load_runner |
not exposed (recompiles from source per start; microsecond-cheap) |
| stdout after resume | captured | not captured on resume (fidelity gap) |
| Tool error into guest | can raise a catchable exception inside the guest | aborts the run (no guest-throwable error in zapcode) |
| Host filesystem | os: handler + pathlib/mount routing |
none — no OS layer at all |
Two structural differences worth calling out:
- No filesystem/OS layer. zapcode's sandbox denies all host access except
registered functions. There is no
os:handler; expose file-like operations as explicit external functions if a guest needs them. - Non-destructive snapshots. Because
ZapcodeSnapshot::dumpborrows rather than moves,dump_snapshot/1leaves the snapshot resumable — you can persist a checkpoint and keep going in-process.ExMonty.dump_snapshot/1consumes.
just test # EXZAPCODE_BUILD=1 mix test (builds the NIF locally)
just fmt # mix format + cargo fmt
just release # interactive version bump, tag, and push (CI builds + publishes)This package depends on jtippett/zapcode,
a fork of TheUncharted/zapcode carrying
correctness patches (spread, array mutation, switch, destructuring params,
regex rejection) that are not yet upstream. The dependency is pinned by commit in
native/ex_zapcode/Cargo.lock. Tracking upstream and rebasing our patches: see
UPDATE_PROCEDURE.md.
MIT © James Tippett. zapcode is MIT © its authors.