Skip to content

JavaScript runtime (code_run)

code_run runs JavaScript on a QuickJS interpreter (quickjs-ng, pinned at v0.10.1) compiled to wasm32-wasi and executed inside the sandbox. It is how the assistant does multi-step computation and data shaping — and, when it needs to, calls a tool through one host function.

The defining property: pure computation needs zero authority. A script that only transforms data never touches the gate. Everything it does to the world is an individual tool call, gated where that tool is gated.

Engine QuickJS (quickjs-ng v0.10.1), ES2023, wasm32-wasi
Tool code_run — input source (a JS program)
Input your script; a runtime prelude is prepended automatically
Output whatever the script prints (console.log / print) → stdout
Tool calls only via nocturn.call(tool, args) — each gated where that tool is gated
Authority none by default; the sandbox caps memory and wall-clock time
  1. Your source is evaluated after the runtime prelude is prepended (so your line numbers are offset by the prelude’s length — worth knowing when reading a stack trace).
  2. Anything you console.log or print goes to stdout and comes back as the tool result.
  3. An uncaught exception ends the run: its message and stack go to stderr and the call fails with that text, so a failure is never a silent empty string.
  4. A runaway script (infinite loop, memory blow-up) is trapped by the sandbox’s memory cap and wall-clock deadline — it cannot hang the host.

The app’s tools view for one turn: a code_run that failed, a second that succeeded with its JavaScript expanded, and the ten file_write calls that script produced listed underneath with their durations.

Above: one turn, one script, and every effect it had accounted for separately. That is the shape to keep in mind — the loop is the model’s, the ten writes are ordinary file_write calls, and each one met the gate on its own.

// Pure compute — nothing gated, nothing asked.
const rows = [3, 1, 2].sort((a, b) => a - b);
console.log(JSON.stringify(rows)); // → [1,2,3]

Every tool call bottoms out at one host function:

const out = nocturn.call(toolName, args);
  • It is synchronous — it returns the tool’s result string directly, no await needed (top-level await also works if you prefer promises).
  • toolName and args are the same names and schemas the model uses — underscores and all. The script dispatches through the same toolset the model does, so it has no more authority than the model, and each call is gated where that tool is gated.
  • A denied or failed call throws, so your script can try/catch it and the host never crashes.
// A tool call — gated exactly like the model calling http_read: net → example.com.
const res = nocturn.call("http_read", { url: "https://example.com" });
console.log(JSON.parse(res).status); // → 200

code_run itself is not callable from within a script: there is no recursive interpreter, and the attempt is refused with a catchable error.

A small hand-written prelude ships a familiar subset of Web/Node APIs on top of QuickJS. It is DevEx sugar, not a security boundary (see below).

API Notes
btoa / atob base64 over a binary string
TextEncoder / TextDecoder UTF-8 encode/decode
Buffer.from(...) utf8 / base64 / base64url / hex only; .toString(enc) back
URL, URLSearchParams pragmatic http(s) parser (not full WHATWG)
Headers, Response, FormData fetch-style value types
require(...) shim for fs, fs/promises, buffer, util only
console.log, print write to stdout
crypto.getRandomValues(view) fills an integer typed array with real entropy, up to 65536 bytes
Date, Math.random() the real clock, and a PRNG seeded from it

crypto.getRandomValues is not part of the prelude — it comes from the interpreter itself, and it is the one to use when a value must be unguessable. Math.random() is a PRNG seeded from the clock, so two calls a millisecond apart are related: fine for jitter, wrong for a token, a nonce, or a multipart boundary. Its entropy is the host’s crypto/rand, reached through WASI, so it costs the guest no host function of its own — see zero ambient authority for why a guest gets a real clock and real entropy at all.

Each of these maps to exactly one registered tool, and that mapping is the whole contract: a wrapper has no permissions of its own. Whatever the tool it calls does — ask, allow, or never reach the gate at all — is what the wrapper does, because it is that tool call.

So the gate status is not repeated here. Follow the tool to its own page; that page is the one place it is stated, and the one place that cannot fall out of step with the code.

Wrapper Tool it calls
fetch(url) / fetch(url, {method:"POST",…}) http_read / http_write
nocturn.resolve(host, type?) dns_resolve
nocturn.ping(host) ping
nocturn.fs.readFile / list / stat / search file_read / file_list / file_stat / file_search
nocturn.fs.writeFile / remove / move file_write / file_remove / file_move
nocturn.notify(message, title?) notify
nocturn.remind(when, message, title?) remind
nocturn.wake(seconds, note) wake
nocturn.now() time_now
nocturn.skillFile(skill, path) skill_read
nocturn.reminders() / nocturn.cancelReminder(id) remind_list / remind_cancel
nocturn.memory.read / nocturn.memory.write memory_read / memory_write
nocturn.whoami() whoami — only registered when speaker recognition is configured
nocturn.knowledge.search(query, limit?) knowledge_search — only registered when an embedding endpoint is configured

The source is internal/script/prelude.js — every wrapper above is a few lines of JavaScript around a single nocturn.call, and reading it is the fastest way to see that the sugar hides nothing. It is the whole guest-side surface, in one file.

const r = await fetch("https://api.example.com/items");
const items = await r.json();
const fs = require("fs");
const text = fs.readFileSync("notes/todo.md"); // file_read
fs.writeFileSync("notes/done.md", text); // file_write
fs.renameSync("notes/done.md", "done/todo.md"); // file_move, on the destination path
const mds = await nocturn.fs.search("*.md"); // file_search
const today = nocturn.now().iso; // time_now

fetch forwards no request headers other than Content-Type — the host owns the credential channel and injects auth at the boundary. Unsupported Node operations (streams, recursive rm, mkdirSync) throw a clear error rather than failing quietly.

A skill can bundle a script next to its SKILL.md, which makes skill_read the other half of code_run: the skill’s instructions name a file, the file is fetched as text, and the text is the program.

const src = nocturn.skillFile("summarize-url", "scripts/extract.js");
// hand `src` back as the source of a code_run call, or eval it in place:
const extract = new Function("html", src);

skill_read is ungated and confined by os.Root to that one skill’s directory, so it is a way to ship code with a skill — not a way to read the workspace. Its companion skill_load pulls a skill’s instructions into the model’s context and is deliberately out of a script’s reach: a script has no context to load into.

  • No ambient I/O. No raw socket, no arbitrary filesystem, no child process. The only way out is nocturn.call (and the sugar over it).
  • No package ecosystem. require resolves the four shim modules and nothing else. No npm, no module loader.
  • Bounded. Memory is capped and a wall-clock deadline traps runaways; a single file_read is capped at 1 MiB (see the file kind).

The prelude runs inside the guest with no more authority than any plugin code. A buggy or malicious shim can do nothing nocturn.call does not already allow — it is convenience, not a trust boundary. The check lives on the host, in gate.Check, and every tool call passes it whether it was written as fetch(...), fs.writeFileSync(...), or a bare nocturn.call(...).

A script’s reach is its caller’s cage. Run from the chat, it dispatches through the workspace’s tools; run inside a plugin, through exactly the base tools that plugin’s uses list names.