CI Hex.pm

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, or require; the only way guest code reaches the host is through the external functions you register

Installation

def deps do
  [{:ex_zapcode, "~> 0.1"}]
end

Precompiled 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).

Quick start

# 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"})

The tool bridge

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.

Low-level interactive API

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})

Durable suspend/resume (save/resume)

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.

Trusted input only

load_snapshot/1 deserializes directly into native VM state. Only load bytes your application produced and stored somewhere trusted.

Value mapping

TypeScriptElixir
number (integer)integer
number (float)float
stringString.t
booleantrue / false
null / undefinednil
arraylist
objectmap (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.

Errors

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.

TypeScript subset coverage

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:

ConstructBehavior
Regular expressions (/re/)rejected at parse time with a clear error (use string methods)
Number.prototype methods (toFixed, …)unsupported → type_error
Dateunsupported (deliberate — no clock in the sandbox) → type_error
await inside a for…of loop bodynot 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).

Relationship to ExMonty

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 modelstart/resumestart/resume (same)
Suspension reasonsfunction call, method call, OS call, name lookup, futures, completefunction call, complete (one reason — simpler)
Tool-call argspositional + kwargs (Python)positional only; call tools with an options object
Snapshot serializationdump_snapshot/load_snapshotconsumes the snapshotdump_snapshot/load_snapshotnon-destructive (still resumable)
Reusable compiled runnerdump_runner/load_runnernot exposed (recompiles from source per start; microsecond-cheap)
stdout after resumecapturednot captured on resume (fidelity gap)
Tool error into guestcan raise a catchable exception inside the guestaborts the run (no guest-throwable error in zapcode)
Host filesystemos: handler + pathlib/mount routingnone — 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::dump borrows rather than moves, dump_snapshot/1 leaves the snapshot resumable — you can persist a checkpoint and keep going in-process. ExMonty.dump_snapshot/1 consumes.

Development

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)

Fork

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.

License

MIT © James Tippett. zapcode is MIT © its authors.