One execution environment. Write it, or let an agent write it.
Write a custom Code node, save a reusable script, or let an agent generate the logic. It all runs in the same QuickJS WebAssembly isolate โ same engine, same limits, same imports.
Capabilities are globals
Host-granted permissions โ fetch, workspace, secrets, media โ are injected as globals for that run.
Libraries are imports
38 built-in utility packs, reached with a standard ES import. No library global, no second route.
Nodes are functions
Call any of 424 AI nodes as a standard async function. await is the edge, a variable is the wire.
A stripped-down guest, and what the host hands it
Your code starts with less than plain QuickJS. Everything past that is a bridge the host built for this specific run, bound to its limits and its abort signal. Nothing in the guest can widen a grant it was given.
A body that streams
One import for the library, one global for the capability, two awaitable calls for the outputs.
// A Code node body. Inputs arrive on `inputs`, never as globals.
import { parse } from "@nodetool-ai/sandbox-csv";
const rows = parse(await media.text(inputs.file)).data;
const kept = rows.filter((r) => Number(r.score) > inputs.threshold);
for (const row of kept) {
await emit("row", row); // streams downstream now
}
progress(100, `kept ${kept.length}`);
await output("count", kept.length); // final, posted when the body endsThe body picks the mode. Mention stream(name) and it runs once for the whole stream, pulling its own items. Leave it out and it runs once per incoming item. There is nothing to configure.
The granted surface
fetch
HTTP with the SSRF guard in front of it. 20 calls, 1 MB bodies, 15 s each, every redirect re-checked.
workspace
read, write, list, stat, copy, move, mkdir, remove. Jailed to the run's workspace root, symlinks resolved before every call.
nodetool.secrets
get, tryGet, list โ narrowed to the names the node declared. There is no setter anywhere in the guest.
image / audio / video
Decode, trim, resize, mix, composite. Transforms return handles, so the bytes stay on the host while you chain calls.
emit / output / stream
The node's IO contract. emit streams a value now under backpressure, output records a final, stream() pulls items as they arrive.
media / canvas / crypto / format
Resolve a ref to bytes, draw on a 2D surface replayed on a real host context, hash with WebCrypto, format with the host's Intl.
Gone before your code loads
The prelude runs first, so there is no window where these exist.
eval and Function
Deleted before a single line of user code evaluates.
setTimeout and friends
Their callbacks would fire outside the run's error contract. sleep(ms) is the only timer.
Ambient modules
A body that imports nothing gets no loader at all. Dynamic import() is denied outright.
Buffer, process, env
The Node-compat preamble never installs them, and the prelude deletes them anyway.
38 built-in sandbox packs
Bodies declare what they need by importing it. Every import is resolved against the installed catalog before the guest starts, so importing something we do not serve fails the node immediately โ not halfway through a run you already paid for.
import yaml from "@nodetool-ai/sandbox-yaml";
import { sigv4 } from "@nodetool-ai/sandbox-aws";
// -aws signs the request. It never sends one: the guest passes the
// signed headers to its own fetch, so the run's cap and its SSRF
// guard still apply.
const signed = await sigv4({
method: "GET",
url: `https://${inputs.bucket}.s3.us-east-1.amazonaws.com/${inputs.key}`,
region: "us-east-1",
service: "s3",
accessKeyId: await nodetool.secrets.get("AWS_ACCESS_KEY_ID"),
secretAccessKey: await nodetool.secrets.get("AWS_SECRET_ACCESS_KEY")
});
const res = await fetch(signed.url, {
method: signed.method,
headers: signed.headers
});
await output("config", yaml.load(await res.text()));Guest-compiled
Lightweight libraries bundled straight into the isolate by esbuild, then scanned and probed before admission
Host-bridged
Heavy workloads, Node builtins, a DOM, or a limit the guest could not enforce on itself โ reached through a generated facade
A pack is two config files
A package.json manifest and a SKILL.md. No shipped pack writes a line of code โ the compiler bundles the npm dependency into the guest and caches it by content digest, never by version.
Host-side when the guest cannot hold it
zip runs on the host because an inflation cap enforced inside the guest is enforced by code the guest can decline to call. tfjs runs there because model weights outlive a run and outsize the 64 MB heap.
A pack cannot bring host code
kind: "host" carries an id, never an implementation, and that id resolves only if a first-party table pins the exact package to it. The per-run dispatcher re-checks on every call.
Pick your paradigm
Every node NodeTool ships is available as an import. We expose them two ways, and which one you want depends on what you need at the end: the values, or the graph that produced them.
@nodetool-ai/sandbox-flow
Use this when you just want the data.
Functions are direct, awaitable API calls. You handle concurrency with Promise.all and branching with if/else. Nothing is scheduled: the call resolves the node from the registry, injects secrets, runs process(), and returns the outputs โ the same execution the kernel performs, minus the actor.
424
node callables
68
namespace modules
0
graphs built
- if, for and try are themselves. The pack ships no control-flow combinators.
- Each callable is generated from the node's own metadata, so its inputs are the node's inputs.
- Recursion is capped at depth 4, with 16 concurrently open streams per run.
import "@nodetool-ai/sandbox-nodetool/flow";
import { agent } from "@nodetool-ai/sandbox-flow/nodetool.agents";
import { textToImage } from "@nodetool-ai/sandbox-flow/lib.image";
// Promise.all is the fan-out. No ForEach node, no scheduler.
const shots = await Promise.all(
inputs.beats.map((beat) =>
textToImage({ prompt: beat, model: inputs.model })
)
);
// A streaming node carries .stream. Breaking early closes it,
// and the node stops.
let notes = "";
for await (const chunk of agent.stream({ objective: inputs.brief })) {
notes += chunk.chunk ?? "";
if (notes.length > 2000) break;
}
await output("shots", shots.map((s) => s.output));
await output("notes", notes);Which one to reach for
sandbox-flow
You want values. Branching, retries and concurrency are plain JavaScript, and every call still opens a node.process span and bills through the run's own context.
sandbox-dsl
You want an artifact โ something to open in the editor, validate, supervise, or hand to the server. The graph is just data until something checks it, so validate before you save.
WorkflowRunner
An actor per node, correlated lineage, end-of-stream propagation. Right for the editor and for supervised runs, pure overhead when the caller is code that just wants a value.
Agents write code, not tool calls
Standard tool-calling is a round trip per call: emit JSON, wait, loop. With CodeAct the agent writes a whole JavaScript program instead, and it runs in the same isolate your Code node runs in. It loops, branches and reduces locally, reaching 208 platform tools as imports across 33 namespaces. Its return value, logs and thrown errors are the observation for the next turn.
import { find_model } from "@nodetool-ai/sandbox-nodetool/models";
import { run_workflow } from "@nodetool-ai/sandbox-nodetool/workflows";
import { thread_memory_save } from "@nodetool-ai/sandbox-nodetool/memory";
// One action. Five tool calls. Only finish() reaches the transcript.
const { results } = await find_model({
capability: "text_to_image",
query: "flux schnell"
});
const beats = ["establishing wide", "hands on the console", "cut to black"];
const runs = await Promise.all(
beats.map((prompt) =>
run_workflow({
workflow_id: "wf_storyboard",
params: { prompt, model: results[0].ref }
})
)
);
const stills = runs.map((r) => r.outputs.image?.[0]).filter(Boolean);
await thread_memory_save({
content: "Storyboard stills for the console scene",
resources: stills.map((uri) => ({ type: "asset", uri }))
});
finish({ shots: stills.length, missing: beats.length - stills.length });Five tool calls, one action, one observation. The payload never leaves the guest โ only the reduction does.
@nodetool-ai/sandbox-nodetool/
33 namespaces, 208 tools. Only what an action imports gets mounted, so you pay for one module's dependency cone rather than the whole registry.
Every capability resolves against the run's own user id, and "missing" and "not yours" are the same answer, so a run cannot probe for ids.
Token savings, by construction
The data stays in the guest isolate. You do not pay to pass giant JSON arrays back and forth to the model โ only the reduction crosses into its context. The model sees execute_code({ code }) and nothing else.
Zero new privileges
An agent gets the same limits and the same toolbelt as a developer writing a Code node. Every imported function is a tool the model could have called directly; an off-allowlist import stops the action before the guest starts, and third-party packs need session consent.
Fully observable
Every call inside an action still surfaces to the host as a tool_call_update, so composition does not become opacity. Tool calls are hard-capped at 50 per action, which stops a runaway loop.
Your code and the agent's are the same program
A Code node body is code a person saved. An action is code the model just wrote. The only difference is what the host granted each one: the packs a session consented to, the secret names in scope, the tools on the belt. Engine, limits, marshaling and imports are one implementation, so what you test by hand is what the agent gets at runtime.
Locked down by default
Hard ceilings: 64 MB guest heap, 30 s of CPU, a 512 KB call stack. A caller can tighten any limit or raise it within bounds. Nobody can switch a protection off, and nothing inside the guest can raise its own. A Code node and an agent action start from the same numbers.
Defaults and ceilings
| Limit | Default | Ceiling |
|---|---|---|
| Execution time | 30 s | interrupt handler on a CPU budget |
| Guest heap | 64 MB | 512 MB |
| Call stack | 512 KB | 8 MB |
| Fetch calls | 20 per run | 100 |
| Fetch body | 1 MB | 50 MB |
| Fetch timeout | 15 s | 120 s |
| Redirect hops | 5 | re-checked at every hop |
| Output size | 100 KB | 10 MB |
| Media handles | 256 MB per run | total encoded payload |
| Tool calls per action | 50 | stops a runaway loop |
No dynamic code generation
No eval, no Function constructor, and dynamic import() is blocked outright. Enforcement sits in the module normalizer, not the loader โ QuickJS serves a cached module without ever consulting the loader.
Scoped secrets, and no setter
A script declares the secrets it needs up front and the bridge refuses every other name, so a node that talks to one service cannot read another's credentials. Writing one goes through the user's own client: request_secret takes a name and a reason, never a value. The credential never enters the guest, the websocket frame, or an LLM context.
SSRF guard, on every hop
The built-in fetch blocks loopback, link-local and private ranges, including IPv6 forms and IPv4-mapped addresses, and validates every single redirect hop. The switch that lifts it is host-set only, so guest code cannot enable it for itself.
Strict workspace containment
workspace.read, write and the rest are jailed to the run's root directory, with symlinks resolved and the real path re-checked immediately before every operation. Widening it to the host filesystem is a host-set switch that defaults off.
Hard abort, suspendable clock
Once the abort signal fires, every bridge call after it fails fast and the guest unwinds. Time parked waiting on a human approval is credited back, so a prompt nobody answers does not kill the program that asked.
The rest of the runtime is yours too
Sandboxed code is where you spend most of your time. Under it sits an actor-model kernel, a node SDK, an HTTP and WebSocket API, and a container image you can run on your own hardware.
Custom nodes in 15 lines
Extend BaseNode, decorate the properties, implement process(). Register the package and the node shows up in the visual canvas, in the DSL codegen, and as a sandbox-flow callable. One definition, every surface.
Native MCP support
The whole toolbelt speaks Model Context Protocol. nodetool mcp install wires up a CLI agent in one command; the .mcpb bundle installs into Claude Desktop by drag-and-drop and hot-attaches when the server appears. A deployed server is reached at /mcp with a token minted in settings.
Self-host anywhere
AGPL-3.0. The deploy unit is one self-contained container image with the frontend and the example workflows baked in: Docker Compose for your own box, or the deploy tooling for SSH, RunPod, GCP and Supabase. Your keys, your files, providers billed directly.
A node in 15 lines
import { BaseNode, prop } from "@nodetool-ai/node-sdk";
export class SentimentNode extends BaseNode {
static readonly nodeType = "my.text.Sentiment";
static readonly title = "Sentiment";
static readonly description = "Score text sentiment from -1 to 1.";
static readonly metadataOutputTypes = { score: "float" };
@prop({ type: "str", default: "" })
declare text: string;
async process(): Promise<{ score: number }> {
return { score: await analyze(this.text) };
}
}From a checkout
git clone https://github.com/nodetool-ai/nodetool
cd nodetool
nvm use # Node 22.22.1, from .nvmrc
npm install
npm run build:packages
npm run dev:nodetool -- serve # HTTP + WebSocket on :7777The @nodetool-ai packages are not on npm yet, so host-side imports resolve from a source checkout. The sandbox specifiers elsewhere on this page are a different thing โ they resolve inside the guest, and every pack listed above ships with the app.
Made with working creatives
NodeTool is open source under AGPL-3.0. Star the project on GitHub, join us on Discord, and share workflows with the artists, motion designers, and studios already using it on real jobs.
Get in
touch
Questions, bug reports, feature requests โ we answer.