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.
At a glance
Section titled “At a glance”| 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 |
How a run works
Section titled “How a run works”- Your
sourceis 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). - Anything you
console.logorprintgoes to stdout and comes back as the tool result. - 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.
- 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.

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]Calling tools: nocturn.call
Section titled “Calling tools: nocturn.call”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
awaitneeded (top-levelawaitalso works if you prefer promises). toolNameandargsare 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/catchit 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); // → 200code_run itself is not callable from within a script: there is no recursive interpreter, and
the attempt is refused with a catchable error.
Built-in APIs (the prelude)
Section titled “Built-in APIs (the prelude)”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).
Pure-compute helpers (no authority)
Section titled “Pure-compute helpers (no authority)”| 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.
Sugar over the tools
Section titled “Sugar over the tools”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_readfs.writeFileSync("notes/done.md", text); // file_writefs.renameSync("notes/done.md", "done/todo.md"); // file_move, on the destination path
const mds = await nocturn.fs.search("*.md"); // file_searchconst today = nocturn.now().iso; // time_nowfetch 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.
Running a skill’s script
Section titled “Running a skill’s script”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.
What it is not
Section titled “What it is not”- 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.
requireresolves 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_readis capped at 1 MiB (see thefilekind).
Security note
Section titled “Security note”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.