Every ProcessingContext carries an AgentMemory instance at context.memory. It is the single source of truth for everything shared between steps, tasks, sub-agents, and tools. Do not introduce a parallel result map in any executor — read and write through context.memory.
Memory contents are NOT auto-injected into prompts. Agents access memory through three capabilities that are auto-attached to every step (and to every team iteration). They are the shared capability module (src/capabilities/shared.ts) — kept apart from memory, which is the user's durable store and a different lifetime — and getSharedTools() (src/tools/shared-tools.ts) is the belt the executors mount them from:
| Tool | Object model | Purpose |
|---|---|---|
list_shared |
nodetool.shared.list(filters) |
Discover available entries (metadata only — keys, titles, kinds, byte sizes) |
read_shared |
nodetool.shared.read(keys) |
Fetch full values for specific keys |
share_result |
nodetool.shared.publish(key, value, {title, description}) |
Publish a value under shared:<key> |
The default execution system prompt documents the nodetool.shared form, and the three wire names drop out of the raw tool catalog the way every other wrapped tool does. The user message names only specific upstream keys the planner pinned (step.dependsOn plus parent-task dependsOn via upstreamMemoryKeys) — values are pulled on demand.
import { memoryKeys } from "@nodetool-ai/runtime";
memoryKeys.step("step_1"); // "step:step_1" — step result
memoryKeys.task("research_phase"); // "task:research_phase" — task result
memoryKeys.input("customer"); // "input:customer" — caller-supplied input
memoryKeys.shared("note"); // "shared:note" — cross-agent scratch| Writer | Trigger | Key | Kind |
|---|---|---|---|
CodeActExecutor |
Step completion | step:<step.id> |
step_result |
CodeActExecutor |
Last step of a task (finish-task) | task:<task.id> |
task_result |
TaskExecutor |
Startup / terminal step failure | input:<key> / step:<step.id> |
input / step_result |
ParallelTaskExecutor |
After a task completes (idempotent) | task:<task.id> |
task_result |
share_result tool |
Agent / sub-agent publish | shared:<key> |
shared |
share_result is restricted to the shared: namespace so agents can't spoof step / task / input results. Internal executors write directly through context.memory.set for their owned namespaces.
A step executor always builds the default execution prompt (the CodeAct action contract, the output schema, the finish() discipline). A caller-supplied systemPrompt is layered as a preamble before the default — it cannot override the execution contract. Earlier versions allowed this and broke result capture in plan mode.
TaskPlanner follows the same rule, and did not until recently: a caller's
systemPrompt replaced the whole TaskArchitect contract — ID rules,
parallelism, output schemas, "synthesis is not your job". Every AgentNode
supplies one (default: "You are a friendly assistant"), so every node-driven
plan was authored without it. It is a preamble now.
The contract's tool-dependent half is conditional on the run's toolbelt. With
tools it carries the find_model / generate_* / save_asset instructions;
with an empty belt it carries ## No Execution Tools instead, and the planner
is told to plan only what a model can do from its own knowledge. Advertising
tools no step can call produced research plans whose every step was the
model's own recall.
There is no synthesis stage. execute_plan runs the DAG, writes each task's
result to task:<id>, and returns those values with the tasks; the loop that
called it then writes the answer on its next turn, reading task:<id> via
read_shared for anything the return did not carry. That is what
execute_plan's own description tells the model to do.
The planner is still told NOT to create an aggregation/synthesis task: a step that assembles the answer duplicates the turn that follows the call, and it does it with a smaller view of the run than the caller has.
ParallelTaskExecutor derives task.dependsOn.map(memoryKeys.task) and forwards it as upstreamMemoryKeys to TaskExecutor, which forwards it verbatim to every step executor. The step's user message renders these as - task:<id> hints next to the intra-task step:<id> deps. The agent calls read_shared when it needs the values.
packages/runtime/tests/agent-memory.test.ts— unit tests forAgentMemorypackages/agents/tests/shared-tools.test.ts— unit tests for thesharedcapabilitieslist_shared/read_shared/share_result, and the beltgetSharedTools()builds from thempackages/agents/tests/memory-propagation.test.ts— end-to-end throughexecute_plan,TaskExecutorandStepExecutor, including a fake-provider round trip that driveslist_shared→read_shared→finish_steppackages/agents/tests/_helpers/mock-context.ts— shared mock context with a realAgentMemoryfor executor tests
When asserting memory writes in tests, prefer context.memory.has(memoryKeys.task("...")) and context.memory.subscribe(...) over spies on set / storeStepResult.
For the full API reference, tool schemas, propagation flow, design decisions, and troubleshooting, see docs/agent-memory.md.
Reader-facing reference for this whole section: docs/javascript-sandbox.md.
User-authored JS from a CodeAct action and nodetool.code.Code runs in a
QuickJS WebAssembly sandbox via @sebastianwessel/quickjs. The guest lives
in its own WASM heap, so runaway or malicious code can't corrupt the host V8
heap the way it could under the previous node:vm implementation.
Windows test portability: filesystem-containment tests must use
tests/_helpers/filesystem-links.ts instead of calling symlink() directly.
The helper creates directory junctions on Windows, so the tests do not require
Developer Mode or elevation, and regular directory symlinks elsewhere.
Normalize paths before comparing them with repository-style / paths, and use
Node itself for cross-platform process-spawn tests instead of POSIX utilities
such as true.
Normalize wrapper-produced module IDs before matching them: QuickJS uses \
on Windows and / on POSIX. Keep native media fixtures on software encoders;
a Windows hardware probe can find a codec that the installed driver cannot
open.
On Node the interpreter runs on a worker_threads worker
(src/js-sandbox-worker/): buildSandbox and every host bridge stay on the
main thread, the worker rebuilds each bridge as an RPC proxy over the port, and
a CPU-bound guest therefore blocks only its own thread — the server keeps
serving, and the websocket stop frame that cancels the run can still be read.
Abort is worker.terminate(), immediately — equally instant for a spinning
guest and a parked one, and nothing of value dies with the thread: logs and
emitted values accumulate main-side through the RPC dispatches, and a cancelled
action leaving no partial state write-back is the cleaner contract. Three
runs stay in-process: the browser (no worker_threads), a run with input
streams (the synchronous stream.open mirror has no handle names to seed), and
NODETOOL_SANDBOX_INPROC=1. NODETOOL_SANDBOX_WORKER=require turns any other
fallback into an error for CI. Under tsx/vitest the worker boots from an eval'd
bootstrap that registers tsx/esm/api — tsx's own --import hook skips worker
threads, and Node's built-in strip-only TS loader cannot parse the workspace
packages. The packaged backend ships the worker as a second esbuild bundle
(scripts/bundle-backend.mjs → backend/js-sandbox-worker/worker-entry.js).
The interpreter itself (js-sandbox-worker/interpreter.ts) is shared by both
paths, so they cannot drift; bridge results must be plain data — a function in
a bridge return value cannot cross postMessage, which is why the fetch bridge
returns body bytes and the guest prelude builds text()/bytes()/
arrayBuffer() over them.
Hard limits enforced by the runtime. Each row's default can be overridden per
invocation via RunSandboxOptions.limits, clamped to the ceiling in the last
column by resolveSandboxLimits:
| Limit | Default | Configured by | Ceiling |
|---|---|---|---|
| Execution time | timeoutMs (30 s) |
setInterruptHandler (CPU budget) + wall-clock race |
— |
| Suspended time | DEFAULT_SUSPEND_ALLOWANCE_MS (30 min), or INPUT_STREAM_SUSPEND_ALLOWANCE_MS (unbounded) for a run with onTakeInput |
RunSandboxOptions.suspendAllowanceMs, only with a clock |
— |
| Guest heap | GUEST_MEMORY_LIMIT = 64 MB |
runtime.setMemoryLimit (limits.memoryLimitBytes) |
512 MB |
| Call stack | GUEST_STACK_LIMIT = 512 KB |
runtime.setMaxStackSize (limits.stackLimitBytes) |
8 MB |
| Fetch calls | MAX_FETCH_CALLS = 20 per run |
counter inside bridge (limits.maxFetchCalls) |
100 |
| Fetch body | MAX_RESPONSE_BODY_SIZE = 1 MB |
truncation inside bridge (limits.maxResponseBodyBytes) |
50 MB |
| Fetch timeout | FETCH_TIMEOUT_MS = 15 s |
per-request AbortController (limits.fetchTimeoutMs) |
120 s |
| Output | MAX_OUTPUT_SIZE = 100 KB |
serializeResult truncation (limits.maxOutputSize) |
10 MB |
| Random bytes | MAX_RANDOM_BYTES = 64 KB |
crypto.getRandomValues clamp |
— |
| Progress reports | MAX_PROGRESS_CALLS = 1000 per run, one per PROGRESS_MIN_INTERVAL_MS = 100 ms |
counter + timestamp inside the bridge | — |
| Host module text input | MAX_HOST_INPUT_CHARS = 5 MB |
check inside host-modules/limits.ts |
— |
| Host module byte input | MAX_HOST_INPUT_BYTES = 10 MB |
check inside host-modules/limits.ts |
— |
sandbox-html matches |
DEFAULT_SELECT_HTML_LIMIT = 100 |
options.limit |
MAX_SELECT_HTML_LIMIT = 1000 |
sandbox-zip inflation |
MAX_UNZIP_TOTAL_BYTES = 50 MB total |
check inside host-modules/zip.ts |
— |
sandbox-xlsx write |
MAX_WRITE_SHEETS = 64, MAX_WRITE_CELLS = 250 000 |
checks inside host-modules/xlsx.ts |
— |
sandbox-ocr words |
MAX_OCR_WORDS = 20 000 |
check inside host-modules/ocr.ts |
— |
image.* input |
MAX_IMAGE_INPUT_BYTES = 25 MB |
length check inside each bridge | — |
| Image / canvas pixels | MAX_IMAGE_PIXELS = 32 M, longest edge MAX_IMAGE_DIMENSION = 16384 |
assertSurfaceSize |
— |
image.decode pixels |
MAX_DECODE_PIXELS = 8 M |
check inside the bridge | — |
| Canvas draw ops | MAX_CANVAS_OPS = 10 000 per render |
count inside renderCanvas |
— |
QuickJS's memory limiter counts its own heap objects; string and typed-array
payloads are not charged against it, so memoryLimitBytes bites on object
allocation, not on new Uint8Array(n).
Secrets are scoped. SandboxLimits.secretScope (host-set, null for an
unscoped run) is the list of names a run may read; the getSecret bridge
refuses everything else, and nodetool.secrets.get/tryGet/list sit on top of
that one check rather than beside it. nodetool.code.Code exposes the list as
its secrets property, empty by default so a node written before scopes
existed keeps the reach it had. Tests: tests/sandbox-secrets.test.ts.
Exposed guest surface: console, fetch, sleep, getSecret,
crypto.{randomUUID,getRandomValues,digest,hmac} (WebCrypto-backed — digest
and hmac take SHA-1/256/384/512 and accept string or Uint8Array input, both
returning a Uint8Array), workspace.{read,write,list,readBytes,writeBytes, stat,root,copy,move,mkdir,remove} (requires a ProcessingContext carrying a
Workspace; remove deletes one file or one empty directory, never a tree;
stat returns {exists, size, isDirectory, isFile, isSymlink, modifiedMs, createdMs, accessedMs} and reports a missing path as exists: false rather
than throwing). Those calls go through context.workspace, not node:fs, so a
Code node behaves the same whether the run's workspace is a folder or a prefix
in object storage; root() answers the real directory when there is one and
/workspace otherwise, and containment is the workspace's own rule rather than
a symlink check here. filesystemAccess: "host" — the lib.os escape hatch a
host opts into, never guest code — still resolves real paths through
node:fs, the pure guest-side helpers
toBase64/fromBase64/toHex/fromHex/parallelMap/createCanvas
(UUIDs come from crypto.randomUUID and UTF-8 from the native
TextEncoder/TextDecoder — the old uuid/utf8Encode/utf8Decode
aliases are gone),
progress(percent, message?), format.{number,date,relativeTime,list},
image.{info,stats,decode,blank,pad,grid,resize,crop,rotate,flip,adjust,composite,convert},
audio.{info,normalize,trim,concat,mix,reverse,fadeIn,fadeOut,repeat},
video.{info,trim,resize,rotate,addAudio,extractAudio,extractFrame},
canvas.measureText (plus canvas.render, the undocumented plumbing behind
createCanvas(...).toBytes()), and any caller-supplied globals. fetch sends
a Uint8Array body as raw bytes instead of JSON. Every one of these is a
capability, not a library — libraries are imports (below).
stream is the input side of emit, built in the guest prelude over one
awaitable host bridge: for await (const item of stream(name)) reads one input
handle in order until end-of-stream, stream.any() interleaves every handle as
[handle, value] pairs, await stream.first(name) takes the next value
(undefined at end-of-stream), and stream.open(name) answers synchronously
whether more can arrive. The host supplies the values through
RunSandboxOptions.onTakeInput (called with null for any()) and the probe
through onStreamOpen; without an onTakeInput every verb throws
NO_INPUT_STREAM_MESSAGE. The guest pulls, so an item nobody asked for stays in
the host's inbox — backpressure costs nothing. Time parked on a take is
clock-suspended: a streaming body's timeout meters its own execution, and how
long upstream takes is the run's cancellation to bound, not the timeout's.
Tests: tests/js-sandbox-stream.test.ts.
progress is fire-and-forget: it reports to
RunSandboxOptions.onProgress, clamped to 0–100 with the message truncated to
500 chars, and is a no-op when the caller passes no sink. nodetool.code.Code
wires it to context.postMessage({ type: "node_progress", … }), the same
channel the Python worker uses, so a long-running snippet drives the node's
progress bar.
image, audio, video, and canvas are the media namespaces. Image and
canvas are host bridges over a real
2D canvas (src/sandbox-media.ts). The backend is picked at first use:
@napi-rs/canvas (Skia) on Node, loaded through importHidden so no bundler
pulls the native addon into a browser graph — it is already staged as an
external by scripts/bundle-backend.mjs — and OffscreenCanvas +
createImageBitmap in the browser runner. Ops: info, stats, decode,
blank, pad, grid, resize (fit: cover/contain/fill), crop, rotate
(grows to the rotated bounding box), flip, adjust (the CSS filter set),
composite (layers with position, size, opacity and globalCompositeOperation
blend mode) and convert. Encoding to jpeg fills transparency with
background, white by default.
image.encode is gone. It took width*height*4 bytes from the guest, so
using it meant building a pixel array in the guest heap and shipping it across
— and every observed use was a backdrop, 6 MB of zeros moved to produce a 6 KB
PNG. After handles it was the largest guest→host payload left in a normal image
run, and the manifest advertised it, so it read as the way to make an image.
Three ops cover what it was reached for, all host-side with nothing crossing:
blank(width, height, {color}) for a surface, pad(image, {all|top|right| bottom|left, color}) to grow the canvas without scaling (what
nodetool.image.CanvasResize does in a graph), and grid([image, …], {columns, gap}) to lay images out (which retired lib.grid.CombineImageGrid) —
which is the whole of "generate two images and combine them", the task that
started this. Host code that genuinely holds pixels keeps the direct path
through encodePixels, which is not on the guest bridge.
image.stats(image) is the counterpart to handles: once the guest stops
holding images it cannot answer anything about one, and decode — the only way
to look — pulls every pixel across, which is the cost handles removed. stats
reads them host-side and answers in a hundred bytes: per-channel mean/min/max,
mean luminance, and whether the image is opaque. It carries no
MAX_DECODE_PIXELS bound because it transfers nothing.
Media transforms trade in handles, not bytes
(src/sandbox-media-handle.ts). Each image, audio, or video op takes a handle,
a media ref (asset:// and every uri media.* resolves), or raw bytes, and
returns a handle: a small plain object
({uri: "sandbox://media/<id>", mimeType, byteLength, width, height}) naming
bytes the host holds for the run. So a chain — and a generated image fed
straight in — moves nothing across the boundary but small objects.
That is the difference between working and not. The guest is a courier: in the shape that motivated this (generate two images, combine them) it never reads a pixel. Carrying them as bytes cost a guest→host copy plus a base64 round trip per hop, several times the payload live in the guest, and past roughly 16 MB a run aborted the runtime at teardown. A long op chain over a single photo-sized PNG went from aborting after a few ops to finishing in seconds once handles replaced bytes.
Audio and video transforms use Mediabunny directly. Browsers use WebCodecs;
Node registers Mediabunny's server codec adapter. No audio-node or video-node
package is imported or exposed to guest code. video.extractAudio returns an
audio handle and video.extractFrame returns an image handle.
Two image exceptions, both returning plain data: info and decode exist
to report what is in an image, and decode is the one call that must hand
over real pixels. image.bytes(handle) is the only door back to encoded bytes
and needs no context — reach for it when the body parses the bytes itself, not
to move an image between calls. media.toImage(handle) promotes a handle to a
durable asset; nothing else writes storage, so a three-op chain does not leave
three rows behind. A handle lives for its run: using a stale one says so rather
than blaming its uri. limits.runMediaBytes (default 256 MB) bounds what one
run may hold — the aggregate the per-call ceilings never covered, so exhaustion
is a sentence naming the limit instead of an Emscripten assertion.
A canvas context is a host object with methods, which the plain-data bridge
contract cannot carry, so drawing is recorded rather than proxied: the guest
helper createCanvas(width, height) returns a surface whose getContext("2d")
takes the ordinary Canvas 2D calls synchronously, appending each to a draw
list, and await surface.toImage({format, quality, background}) ships the whole
list through canvas.render to be replayed against a real context and encoded,
answering with a handle like every other image producer. toBytes() keeps its
name's promise and pays for the bytes explicitly.
drawImage takes image bytes, not an image object. Gradients work the same way
— createLinearGradient returns a tagged handle that the renderer swaps for the
real gradient when it is assigned to fillStyle. The method and property
allowlists live in src/sandbox-canvas-api.ts, read by both the guest recorder
and the host replay, and an op naming anything outside them is refused. The
recorded ops are marshaled out of the guest one object at a time (~1 ms each),
which is what MAX_CANVAS_OPS really bounds — for heavy composition reach for
image.composite instead of tens of thousands of primitives.
canvas.measureText(text, font?) returns text metrics so text can be laid out
before it is drawn.
Libraries are imports, never globals. Every library the sandbox offers is a
sandbox package a node imports at the top of its body — the import is the
declaration; there is no data.* namespace any more. Two kinds:
- Guest packs — the M3 compiler bundles the library into QuickJS:
@nodetool-ai/sandbox-yaml(js-yaml),-dates(date-fns),-markdown(marked). - Host packs (
src/host-modules/) — the library runs where the sandbox runs, behind a generated ESM facade over a per-run dispatcher:-csv(papaparse),-html(cheerio + turndown),-xml(fast-xml-parser),-xlsx(exceljs),-zip(fflate),-diff(diff),-ocr(tesseract.js),-tfjs(TensorFlow.js and its model zoo),-docx(docx),-mammoth(mammoth),-epub(epub2),-pptx(office-text-extractor),-tokens(js-tiktoken). These are the libraries the guest cannot hold — Node builtins, a DOM, a file path instead of a buffer, a limit the guest could not enforce on itself, or state that has to outlive a run. Four more are NodeTool's own code rather than a library:-aws(SigV4 signing),-notion,-supabaseand-twiliobuild an authenticated request and return it; the guest sends it with its ownfetch, so the fetch cap and the SSRF guard still apply. They replace the S3/Notion/Supabase/Twilio nodes. Apify was a fifth and is not: handing the guest a token to fetch and poll with is the wrong shape for a paid service running third-party code, so it became theapifycapability module instead (docs/apify-integration.md).
The host-JS analog of the WASM path below, and the same mechanism. A pack's
manifest entry is {"kind": "host", "host": "<id>"} — an id, never code.
SANDBOX_HOST_MODULES (@nodetool-ai/protocol) is the registry: it names every
id, the one package allowed to declare it, and its exports. The specifier
resolves to generateSandboxHostFacade's output — one async export per registry
export plus a default namespace — importing the private nodetool:host-bridge
module, which the loader serves only to generated facades.
createSandboxHostDispatcher is the boundary. It refuses a resolution naming an
unknown id or claiming another pack's id, then validates the module key, the
export name and the argument list on every call before an implementation is even
loaded. The dispatcher binding is deleted before the user IIFE starts; a module
that grabs it during linking gains nothing beyond the run's declared surface.
registry.ts loads each implementation lazily, and each implementation imports
its library lazily inside itself — so nothing sits in an entry graph, esbuild
still inlines them into the packaged server.mjs, and Vite resolves the browser
builds for the in-browser runner, where the "host" is the page. Results go out
as plain data with bytes tagged at any depth (toGuestBytesDeep,
sandbox-bytes.ts), and errors as tagged objects — the marshaling rule every
bridge follows.
Safety limits live inside the implementations, where nothing can route
around them: MAX_UNZIP_TOTAL_BYTES in zip.ts, the select limits in
html.ts, the write caps in xlsx.ts, the shared input caps in limits.ts. Tests:
tests/host-modules.test.ts (libraries end to end, the dispatcher's refusals,
a forged manifest, every limit) and
packages/sandbox-compiler/tests/packs.test.ts (every shipped pack through the
real install path).
The guest's own loader serves only the sandbox packages a run may reach — for a
Code node, the packs its body statically imports, resolved against the host's
catalog; for a CodeAct session, its allowlist. Dynamic import()
and require never resolve. The browser runner fetches those modules over
GET /api/sandbox-modules/* and verifies each body before it runs, so the same
rules hold client-side.
A session that allows packages also carries get_sandbox_package_docs
(capabilities/packs.ts): it serves one pack's SKILL.md, refuses a
specifier off the session allowlist, and wraps the body of a pack the operator
has not put on the pack-loader allowlist in <untrusted-package-docs> — read
as reference, never as instructions. The ambient prompt tier stays one
sanitized, capped line per allowed specifier.
sandboxPackageSkills (codeact/sandbox-package-docs.ts) turns a trusted
pack's skill into an ordinary AgentSkill, which is the injection tier the
untrusted path is contrasted against. No host mounts it. Its one caller was
the retired Agent, so a trusted pack's body reaches the model only through
get_sandbox_package_docs. Wiring it into the chat session's skill catalog is
the follow-up; nothing depends on it being absent.
A pack may declare a WASM module. Its specifier resolves to a generated ESM
facade (generateSandboxWasmFacade, @nodetool-ai/protocol) with one async
export per manifest export, calling a per-run dispatcher through a private
bridge module. The call contract is scalar-only: i32/f32/f64, at most 8
arguments, at most one result, and a void export resolves undefined.
Stateless by contract. Each call instantiates fresh from the cached module inside the worker, runs, and discards the instance — mutable globals and linear memory never carry from one call to the next. A pack needing state keeps it in guest JS and passes scalars in.
| Bound | Default (also the ceiling a manifest may lower to) |
|---|---|
| Worker pool (process-wide) | 4 — not manifest-configurable |
| Call concurrency per invocation | 2 |
| Calls per invocation | 256 |
| Aggregate WASM wall clock per invocation | 30 s |
| Per-call timeout | 5 s, then the worker is terminated and replaced |
The aggregate wall clock is a reservation, not a meter read on completion.
A call is admitted only after taking min(perCallTimeout, remaining) out of
the budget, so concurrent calls divide one cap instead of each seeing the whole
remainder, and the reservation is what bounds that call's timeout. On
completion the reservation is replaced by the real duration — a fast call
refunds, one cut at its timeout keeps the charge — so the budget stays the sum
of call durations it documents.
Cancelling the run reaches the worker, not just the caller. The signal is rechecked after every await on the way to dispatch — compiling a module and waiting for a concurrency slot both outlive an abort — and it is passed into the pool, where it terminates the worker the way a timeout does. A guest export has no yield point, so abandoning the promise would leave the thread spinning for the rest of its timeout on work nobody wants.
The dispatcher is the boundary, not the hiding: it serves only the run's
declared WASM modules and validates module identity, export allowlist, argument
count, and argument type before any worker runs — i32 rejects out of range
rather than wrapping, f32/f64 take NaN and infinities. The bridge module
is refused to every importer but a generated facade, and the dispatcher binding
is deleted before the user IIFE starts. A pack module that grabs the binding
during linking gains nothing beyond the run's own declared surface.
workers.ts is the platform seam: Node worker_threads, browser Web Worker,
both created from an inline source string so no entry file has to survive tsx,
vitest, dist/, and the esbuild backend bundle. The browser path is written
but unexercised here — no browser harness lands until M2.
Fixtures: tests/fixtures/sandbox-wasm/ (the reference module, its WAT, and the
contract cases as data). Tests: tests/js-sandbox-wasm.test.ts (end to end,
real workers), tests/wasm-sandbox-host.test.ts (conversion, budgets, pool).
format exists because QuickJS ships no Intl: each member is a host bridge
over Intl.NumberFormat, Intl.DateTimeFormat, Intl.RelativeTimeFormat and
Intl.ListFormat, defaulting to locale en-US. All four are async (they follow
the never-reject convention), so a bad locale or option arrives in the guest as
a thrown Error carrying Intl's own message. eval and Function are deleted at init so the user cannot
re-enter dynamic code generation. Core JS (JSON, Math, Date, Map,
URL, TextEncoder, etc.) is QuickJS's native implementation, not a
host-bridged version.
Async concurrency: a bridge call starts its host-side work when invoked,
not when awaited, so Promise.all/allSettled/race/any over fetch,
workspace.* or any other bridge run the host operations in parallel — five
fetches under Promise.all take one round trip. parallelMap(items, fn, concurrency?) is the bounded form (order-preserving, default 5, max 32,
rejects on first failure). The per-run fetch cap counts parallel calls the
same as serial ones. Timer globals (setTimeout, setInterval,
setImmediate and their clears) are deleted inside the user-code module —
the engine re-installs host-backed versions on every evaluation, so the
prelude alone can't remove them; wrapCode does. sleep stays the only
timer. Tests pinning all of this: tests/js-sandbox.test.ts
("async concurrency").
State sync-back: object-typed globals are deep-replaced on the host after
the guest runs, so CodeNode's state object persists across invocations.
Primitive globals pass by value (no sync).
Suspending the clock (createSandboxClock, RunSandboxOptions.clock): the
timeout bounds guest execution, and a program waiting on a permission prompt
is not executing — it is waiting on a person. Charged to the same budget, the
wait kills the program that asked, and the answer then resolves nothing. A
caller that owns such a wait wraps it in clock.suspend(); the suspended time
is added back to timeoutMs, so the program resumes with the budget it had.
Suspensions nest, and the engine's own abort moves out to
timeoutMs + suspendAllowanceMs as the backstop for a prompt nobody answers.
The interrupt handler still cuts a runaway loop at exactly timeoutMs of
running time. The websocket chat runner owns one clock per turn and suspends it
around every tool- and plan-approval round trip.
Known QuickJS limitations:
url.searchParams.set(...)doesn't propagate back to the parent URL. Build the query viaURLSearchParamsdirectly.- Host async functions must never reject —
js-sandbox.tswraps them in aneverRejectadapter that returns a tagged error object, which a guest prelude rewraps into a realthrow. Working around a known handle leak in@sebastianwessel/quickjs@3.0.1(tracked aslist_empty(&rt->gc_obj_list)assertion on runtime dispose). - Every
Lifetimetaken from the engine must be disposed, including the onectx.getArrayBuffer()returns. The typed-array serializer dropped it, so eachUint8Arraycrossing guest → host leaked a handle and a run moving ~16 MB tripped the samelist_empty(&rt->gc_obj_list)assertion — after the guest had computed its answer, so the result was produced and then thrown away. Fixed; pinned by "guest→host binary volume" intests/js-sandbox.test.ts. - The wrapper's marshaler recurses on a function: a function's prototype
chain leads back to itself, so
console.log(fn)— or logging a module namespace, which is whatimport * as nsgives — blew the host stack, left handles alive, and aborted the runtime on free with the samelist_empty(&rt->gc_obj_list). The whole run died, after its work was done, for a debug print.console.*arguments are formatted guest-side now (the init prelude wraps the host console), so only strings cross: a function prints as[Function: name], a cycle as[Circular], and everything else exactly as before. Pinned by "logs a function without killing the runtime" intests/js-sandbox.test.ts. - An engine failure never reaches
runInSandbox's ownawait: an Emscriptenabort()arrives as a WASMRuntimeError, and a marshaling failure (guest OOM while a host return value is written into the guest) throws inside a promise continuation the library never catches. Unhandled, that killed the host process and the tool call it was serving returned nothing — a chat turn that simply stopped, with nothing for the agent to read or retry.guardHostProcessclaims those rejections and fails the run;describeEngineFailureturns them into an actionable message instead of raw assertion text. Anything not attributable to the engine is re-thrown, so a genuine host bug still crashes. - The wrapper's Node-compat bootstrap is served cheaper sources
(
src/sandbox-bootstrap-modules.ts). Before our sandboxed function is called it compiles ~12KB of polyfills —node:buffer,node:util,node:url,Headers,Request,Response— into the fresh runtime, and the init prelude then deletesBuffer,Headers,RequestandResponse. Those imports resolve through our module loader, so four are served as empty modules andnode:utilas theTextEncoder/TextDecoderpair the guest keeps;node:urlstill comes from the wrapper, becauseURLandURLSearchParamsare capabilities and a hand-rolled URL parser is not a saving worth making. Measured: 5.96ms → 3.28ms of per-run setup, against 0.34ms for a bare QuickJS context. A wrapper release that renames these modules loses the saving silently, sotests/js-sandbox-modules.test.tsasserts that every id we stub is one the bootstrap actually requests. - Structured data crosses as JSON, not as handles
(
src/sandbox-json-transport.ts). The wrapper marshals an object node by node — fourevalCodecompilations plus a descriptor copy per property — so a run handed 5 000 rows spent ~2 s reading them and ~0.8 s handing them back before running a line of its own. The guest now builds its result withJSON.stringifyand the host parses it, the host encodes injected globals for the prelude to parse, andemit/outputarguments take the same path; measured 17–35× on those shapes. Typed arrays and strings over 8 KB ride a sidecar the marshaler moves whole, so bytes keep the fast native serializer and a megabyte of text is not escaped and unescaped. Dates, non-finite numbers andundefinedproperties carry markers. A value the encoder cannot represent (a function, a bigint, aMap, a class instance) falls back to the wrapper's marshaling; a cycle is refused by name, because the fallback follows it until the runtime aborts. Benchmark:npm run bench:sandbox --workspace=packages/agents. - Binary crosses the boundary asymmetrically. Guest → host is handled by the
typed-array serializers (
addSerializer), so a guestUint8Arrayreaches a bridge as a native one. Host → guest is not: a returnedUint8Arrayarrives in the guest as a numeric-keyed plain object. Bridges that produce bytes therefore return a base64 marker object and the guest prelude rebuilds a realUint8Array— the pattern to follow for any new binary bridge. serializeResultscans for typed arrays at any depth. It used to look only one level in, so binary nested deeper fell onto theJSON.stringifypath, where aUint8Arraybecomes{"0":137,"1":80}— lossy, and indistinguishable from a user's own integer-keyed map. The streaming path hit this every time, sincegenProcessreturns an array of yielded objects and the bytes are always at depth 2. The walk is cycle-safe and depth-capped (SERIALIZE_MAX_DEPTH); a cyclic value still falls through toString.
Every session runs the unified agent loop; -a, --agent and --no-agent are
accepted for backwards compatibility and do nothing.
# Start a session
nodetool-chat
# With specific provider and model
nodetool-chat --provider anthropic --model claude-sonnet-5
# With workspace directory
nodetool-chat --workspace /path/to/project
# Connect to WebSocket server
nodetool-chat --url ws://localhost:7777/wsecho "Summarize this codebase" | nodetool-chat --provider anthropicCOMMANDS in packages/cli/src/app.tsx is the list; anything else prints
Unknown command. There is no /agent: every session runs the unified agent
loop.
/help — Show available commands
/new — Start a new chat session
/clear — Clear conversation history
/compact — Summarize conversation into retained context: /compact [instructions]
/model — Set model: /model <model-id>
/provider — Set provider: /provider <name>
/tools — List enabled tools
/exit — Exit the chat
/quit — Exit the chat
An objective is one CodeAct turn over the same belt chat uses, so there is no
second loop to configure. packages/cli/src/commands/agent.ts assembles it:
buildCliAgentBelt({ ..., planning: true }) puts create_plan and
execute_plan on the belt, createCliCodeActTurn turns that belt into the
execute_code action plus the direct tools, and processChat runs the turn.
The model decides whether the objective wants decomposing; nothing plans on its
behalf.
The stream is the same ProcessingMessage union every host emits — chunk,
planning_update, task_update, tool_call_update, tool_result_update,
step_result, log_update — which is why --json output and the chat
renderer read the same events.
To build a session of your own, use createChatCodeActSession
(src/codeact/chat-codeact.ts) rather than assembling an executor: it owns the
system prompt, the sandbox package allowlist, the resident/direct tool split,
and the clock that stops the action budget while a permission prompt is open.
A capability is what replaced the Tool class: a spec (wire name,
description, input schema, and a required PermissionCategory), an impl
(run, args) => result, and a CapabilityRun that carries everything only
a run knows — the ProcessingContext, the permission gate, the browser router
for ui_*, the sub-agent runtime, and the injected singletons
(nodeRegistry, providers, examples, exportDsl, loaders). Per-run state
arrives at call time, not at construction time, which is what lets one
process-level registry serve every host. Design:
docs/tool-class-retirement-design.md.
registry.ts is one table, CAPABILITY_MODULES, with one entry per namespace
carrying both halves: a lazy loader — one import(), so no
implementation sits in an entry graph — and an eager specs list. Every
module has a data-only sibling (workflows.specs.ts, media.specs.ts, …)
holding wire name, description, JSON schema, category and message template, and
importing no implementation, so capabilitySpec(name) and
listCapabilitySpecs() answer synchronously. That is what lets a belt be
assembled synchronously from the registry: toolFromLazyCapability(spec, run) /
toolForCapabilityName(name, run) (capabilities/lazy-tool.ts) return a Tool
whose spec is there at assembly time and whose implementation loads from its own
module at the first process() — Tool.process() was already async, so only the
spec ever had to be eager. getBuiltinTools(), getAllMcpTools() and
getGoogleWorkspaceTools() all build this way; the one-line
extends CapabilityTool subclasses they replaced are gone, and so is
CapabilityTool itself — toolFromCapability(spec, impl, run)
(capabilities/adapters.ts) hands the implementation it already has to
toolFromLazyCapability's optional impl argument.
A module imports its own specs back and attaches each to an implementation, so
one spec object stands behind both halves, and eagerSpecDrift compares them
by identity — a module that copied its spec would pass a field check and still
be two things to keep in step. CapabilitySpec.zodSchema carries the Zod schema
for the capabilities whose identity is one (view_image, list_images, the
ui_* document tools, the settings and packs namespaces). It is checked
once, by validateCapabilityArgs (capabilities/args.ts): in
LazyCapabilityTool.process() on the belt path, in gatedCall on the invoke
path. Tool.execute does not pre-parse — LazyCapabilityTool exposes no
schema — so neither entrance validates twice and neither skips it.
What the registry deliberately does not serve is written down where the API
it mirrors lives: packages/websocket/src/trpc/sandbox-coverage.ts classifies
every tRPC procedure as covered by a capability, reachable through a
differently-shaped one, withheld with the risk stated, or a recorded gap.
packages/websocket/tests/sandbox-api-coverage.test.ts walks the live router
and fails in both directions, so a new procedure cannot land unclassified and a
stale verdict cannot outlive what it judged. The rule the withheld set follows:
a run may act on the rows its own user owns, and may not touch credentials,
billing, other tenants, host control, the transcript of its own behaviour, or
anything that grants a third party access.
DECLARED_CAPABILITY_MODULES is the module list a reviewer reads, derived from
CAPABILITY_MODULES — a declared module with no loader, or a loader nobody
declared, cannot occur, because both come from the same entry. Three drift
walks keep the rest honest. capabilityModuleDrift() reports an export with no
name, description, schema, category or implementation
(capabilityModuleIssues), a spec object a module rebuilt instead of importing
from its .specs.ts sibling (eagerSpecDrift), and one name owned by two
modules; tests/capabilities-registry.test.ts also pins a
checked-in name → category snapshot, so a reclassification is a one-line diff.
tests/capabilities-coverage.test.ts walks the other way: everything
getBuiltinTools() and getAllMcpTools({}) assemble must resolve through
findCapability, or sit in that file's pinned exception list with a reason.
invoke.ts holds the one ladder — lookup, gate, impl. Every entrance runs it:
the guest dispatcher, a direct MCP registration, run_subtask's child loop.
gateTools (capabilities/gate-tools.ts) is the door a Tool walks through:
it wraps each tool in a subclass whose process() builds a one-call run over
capabilityFromTool and calls invoke. tests/capabilities-gate-parity.test.ts
drives both entrances and compares transcripts.
The guest reaches a namespace by import, never by a global:
import { list_workflows } from "@nodetool-ai/sandbox-nodetool/workflows";
const { workflows } = await list_workflows({ limit: 20 });Exports carry the wire name — list_workflows, not listWorkflows — so the
prompt, the MCP surface and the belt all say one string. The facade generator
and specifier shape live in @nodetool-ai/protocol
(sandbox-capability.ts); the module list stays in the registry here, and the
host decides which modules a session mounts. A third-party pack can never
declare one. createCapabilityDispatcher validates module key, export name and
argument list on every call, then delegates to invoke — it never gates on its
own, so the import path and the belt bridge reach one implementation past one
gate.
coerceCapabilityArgs (capabilities/args.ts) is the argument half of that
check, and it both folds and refuses. It folds camelCase onto snake_case, a
lone string onto the first required string field, and a bare id onto the one
required *_id key — only when there is exactly one, so nothing is guessed.
Then it refuses a call that brought arguments but not the required ones,
naming the call, the missing keys and what was passed instead. Before that,
get_workflow({ id }) reached the implementation as workflow_id: undefined
and answered "Workflow undefined was not found" — a report about a missing
workflow for what was a misspelled argument, and one an agent read as a broken
database. It cost six calls and three wrong theories in one session. A call
with no arguments is still passed through: {} is the documented shape for
the capabilities that require nothing, and for the rest the implementation's own
"x is required" already names the field. The flat tools.<name>() global is gone: every capability is an import,
and what a session added at its own call site is grafted onto .../session
(client ui_* tools onto .../ui) so one import shape covers everything. The
nodetool object model stays a global.
Import direction is one-way: capabilities/ imports
tools/tool-permissions.ts, never the reverse. That is why gateTools sits
in capabilities/ and not beside the classification map it uses. The reverse
edge made the bundled backend's module wrappers an async cycle —
init_tool_permissions awaiting init_adapters awaiting
init_tool_permissions — which esbuild's __esm cannot break the way real ESM
breaks a synchronous cycle, and server.mjs died on an unsettled top-level
await before it served /health. npm run backend:smoke is the check that
catches it; a passing vitest run will not.
SurfaceEntry.paths in the harness registry is coarse on purpose:
packages/agents/ is one path, so a diff that adds a capability lights up
exactly the checks a diff that renames a local variable does. Five capabilities
shipped through that hole before anyone noticed nothing exercised them
(#5095, #5100, #5103, #5105, #5107).
So there is a second table one rung down:
packages/cli/src/harness/capability-table.ts. One entry per exported
capability, naming the file that implements it, the checked-in suites a
selfcheck runs over it, the eval cases whose expect.requiredTools demand it,
and — where nothing does yet — a written gap note. The invariant is the
registry's: no capability without a check or a documented gap.
Everything mechanical is derived, so the table cannot rot:
npm run capabilities:sync # rewrite the table from the live registry
npm run capabilities:check # fail if it is stale (the Quality Gate's typecheck leg)
npm run dev:nodetool -- harness capabilities [--json] [--strict]name, module, contract, suites and evals come from
scripts/sync-capability-coverage.mjs, which reads the live specs, scans the
agent suites, and imports the eval case files. The one hand-written field is
gap, and the sync preserves it. A capability that names every other
capability by construction — the category snapshot in
capabilities-registry.test.ts, the belt walk in capabilities-coverage.test.ts
— is excluded from suite attribution; counting them would make everything look
covered.
Adding a capability means adding its check. Write the eval case or the
suite first; npm run capabilities:check fails on a new capability with
neither, and it will not accept a gap note that still says TODO. Changing what
a capability declares — its description, input schema, category, or
needsToolCallId — moves its contract fingerprint, and
nodetool harness gate --base <ref> then refuses a diff that left the coverage
mapping untouched: say which case covers the new contract, or write down why
there isn't one. A refactor that leaves the contract alone demands nothing and
runs the mapped checks as usual.
The workflow/node/job/asset tools call NodeTool's own code, never HTTP. There
is no NODETOOL_API_URL, no fetch, and no server that has to be listening:
| Concern | Where it comes from |
|---|---|
| Workflows, jobs, assets | @nodetool-ai/models (Workflow, Job, Asset) |
| Running / debugging a workflow | runWorkflow in @nodetool-ai/execution/service |
| Interactive escalations | submitEscalationVerdict + the debugSessions registry, same module |
| Debugging an app | runApplicationDebug, same module |
| Building an app | runApplicationBuild (src/app-build/build-service.ts) |
@nodetool-ai/execution/service is the layer the REST routes call too, so a
tool result and the endpoint's response are one function's answer and cannot
drift. packages/websocket keeps the Fastify routes, auth and WS transport as
thin adapters over it.
Three things live above this package in the dependency order and arrive by
injection through getAllMcpTools(options):
registry— aNodeRegistry. Node discovery needs it, and so does anything that executes. Without one those tools answer with a "no node registry in this process" error instead of reaching for a network fallback.examples— the shipped example-workflow catalog (JSON inside the installed node packages; only the server walks the metadata roots).exportDsl—workflowToDslfrom@nodetool-ai/dsl.
The server builds all three in packages/websocket/src/mcp-tool-deps.ts and
spreads mcpToolHostDeps() into every getAllMcpTools call site.
A fourth, providers, adds find_model and list_models — and only those.
They enumerate the injected map, so without one they can say nothing but "no
providers configured". The media tools (generate_image, generate_speech, …)
used to be added beside them and are built-ins now: each reaches a provider
through context.runProviderPrediction and reads nothing off the run, so the
map was never their dependency. Gating them on it meant a host that injects
none — a Code node, a JS script — got a belt that could critique_image and
score_image_adherence but had no way to make an image, and
nodetool.media.generateImage threw tool "generate_image" is not in this toolbelt after the run had paid for the prompt that produced its argument.
Pinned by tests/sandbox-belt-reach.test.ts.
The headless path from a written script to voiced takes and an assembled
voiceover sequence. The editor voices a line over the chat WebSocket's
generate_media / transcribe_audio RPCs, and the nodetool.script.* nodes do
it inside a workflow; an agent outside the browser had neither. These call the
provider directly, save each take as an asset, and write it back onto the
persisted script.
| Tool | Does |
|---|---|
list_scripts |
Scripts newest first, with line and voiced counts |
get_script |
Cast, lines, and each line's voicing status |
voice_script_lines |
TTS per line → a take, current on its line |
assemble_script_timeline |
Voiced takes → a saved timeline_sequences row |
get_script reports the status the editor's gutter shows — draft (never
voiced), stale (text or voice changed since the take), voiced, no_voice —
and voice_script_lines defaults to every line that is draft or stale, so a
whole script is one call. Each line uses its own voice (its override, else its
speaker's) unless the call passes provider+model+voice to override them all; a
half-specified override is an error, not a guess. Lines are voiced concurrently
(default 3, max 8, 60 per call) and each take lands through a CAS on the row's
updated_at.
Synthesis delegates to GenerateSpeechTool, so the encoded/streaming-PCM
provider split is handled in one place. Word timings come from a best-effort ASR
pass (whisper-1 by default, transcribe: false to skip it) and ride into the
assembled clips as captions. Take duration is ffprobe's answer, falling back to
the last word timing and then to the 3s placeholder — a take stays assemblable
without an exact length.
The voice rule (effectiveVoice), the staleness rule (needsVoicing) and the
script → timeline mapping (buildScriptTimeline) live in
@nodetool-ai/timeline; the editor's "Send to timeline" and
nodetool.script.ScriptToTimeline call the same functions, so the three
surfaces cannot drift. Re-assembly rewrites this script's voiceover track in
place and keeps clips other surfaces added.
Tests: tests/script-voice-tools.test.ts (in-memory DB, fake provider — no
network).
The headless path from a directed storyboard to rendered media and an assembled
cut. The editor has always had this path — the Storyboard surface builds a
throwaway TextToImage → Output / ImageToVideo → Output graph per shot and
runs it in the browser — but an agent outside the browser had to author, save,
and run a workflow per shot, or drive the ui_storyboard_* tools, which only
work while that board is open. These call the provider directly, save each
result as an asset, and write it back onto the persisted board.
| Tool | Does |
|---|---|
list_storyboards |
Boards newest first, with per-board still/clip counts |
create_storyboard |
Blank board (then edit_storyboard adds shots) |
get_storyboard |
Shots with ids, status, and whether each has a still/clip |
direct_storyboard |
Runs the Director over the board's brief/genre/style and writes the screenplay; redirect keeps retained shots' ids and media |
render_storyboard_stills |
text_to_image per shot → the shot's keyframe |
render_storyboard_clips |
the shot's clip: image_to_video seeded by the keyframe, or text_to_video from the prompt |
revise_storyboard_clip |
video_to_video revision of one shot's clip |
assemble_storyboard_timeline |
Rendered clips → a saved timeline_sequences row |
edit_storyboard is the document half: beside the shot ops it carries the
guided flow's set_setup, the scene ops (move_shot, duplicate_shot,
update_scene, create_scene, merge_scene), set_style, and the version ops
(select_version, delete_version, add_keyframe_version). Every one mirrors
a ui_storyboard_* browser tool and writes the same document shape, so a board
edited headlessly and one edited in the editor cannot diverge (PRD § 7.10).
Both render tools take targets (shot ids, indexes, or slugs) and default to
"whatever still needs this step", so a whole board is one call. stale_only
narrows that to the shots whose selected version was rendered from inputs the
shot no longer has (isVersionStale), which is how a style change is re-rendered
without re-rendering the board. Shots render
concurrently (default 3, max 8, 24 shots per call). Every write is a CAS on the
row's updated_at with a bounded retry, because concurrent renders all land on
the same board document; a conflicting write re-reads and re-applies rather than
clobbering.
The provider and model come from the call, else from the board's own
imageModel / videoModel. There is no fallback default — an unset model is an
error naming find_model, not silent spend on a model nobody chose.
Shot.render_mode decides whether a clip needs a still. "keyframe" (the
default) animates the selected still; "direct" renders from the prompt and
skips the stills step entirely, for shots where first-frame conditioning is the
wrong trade — heavy motion, which I2V renders stiffer than the same model's
T2V, and native-audio models, which are weakest on their image path. A direct
shot's prompt carries framing and board style, since no still carries them.
render_storyboard_clips takes mode to override every selected shot for one
call without editing the board.
Prompt composition, entity seasoning (entitiesForShot, @nodetool-ai/protocol)
and the shot → timeline mapping (buildStoryboardTimeline,
@nodetool-ai/timeline) are the editor's, so a board rendered headlessly matches
one rendered in the UI. Board entities are library assets carrying a
metadata.nodetool_entity marker; their descriptors and first reference image
ride along as the entities param, which the runtime expands at the provider
layer.
Tests: tests/storyboard-render-tools.test.ts (in-memory DB, stubbed
predictions — no provider calls).
Five read-only capabilities that measure media instead of describing it:
analyze_audio, analyze_audio_spectrum, detect_audio_events,
analyze_video, detect_video_scenes. understand_video hands a clip to a
multimodal model and gets prose; these hand back numbers a model cannot derive
from watching — loudness against a delivery target, which octave band the
energy is in, where the silences and onsets fall, how much the picture moves,
where the cuts are.
The split is deliberate. src/analysis/audio-dsp.ts and video-frames.ts are
pure functions over Float32Array samples and RGBA pixels — no decoder, no
context, no I/O — so tests/audio-dsp.test.ts and tests/video-frames.test.ts
check them against signals whose answers are known without running the code: a
1 kHz sine's spectral centroid is 1 kHz, a solid grey frame has zero contrast,
and the BS.1770 reference signal reads -20.0 LUFS. media-decode.ts is the
Mediabunny seam (PCM out of an audio track, RGBA frames at chosen timestamps,
container metadata), and the capability file resolves the reference, picks the
analysis parameters, and shapes the answer.
src/analysis/mediabunny-runtime.ts is where the Node codec adapter is
registered, once per process. sandbox-av-media.ts imports it rather than
keeping its own copy — two bootstraps racing to register the same adapter is
the kind of thing that works until it does not.
Three properties worth keeping when changing any of this:
- No ffmpeg. Mediabunny decodes, so an install with no managed runtime
tools still answers.
nodetool.video.GetVideoInforeturns a record of zeros whenffprobeis missing; that is the failure mode this exists to avoid. - Every answer is bounded, and says so. Series are decimated to a point
budget (
decimated), decoding stops at a duration cap (truncated), and video frames are never all held at once —forEachVideoFramecloses each one after the callback, because a few hundred 1080p RGBA frames is gigabytes. - A measurement that means nothing says so.
tempo.reliableis false below four onsets or below a confidence floor, andintegrated_lufsis null rather than-Infinityfor audio shorter than one 400 ms gated block.
That last one was earned. Spectral flux was raw rectified difference, and a
held 1 kHz sine reported 48 onsets at a confident 87 BPM: at a hop that is not
a whole number of cycles, a stationary tone's leakage pattern shifts between
frames, so its bin magnitudes wobble forever. spectralFlux normalizes by the
frame's own magnitude — the wobble is ~2%, a real onset is a large fraction of
1 — and detectOnsets carries an absolute minStrength floor beside its
adaptive one, because where the curve is flat the local deviation collapses and
every ripple clears mean + 1.5σ.
preview_timeline_frame composites a timeline at chosen timecodes and returns
one image handle per frame. It is the only way an agent sees what a cut looks
like: validate_timeline reads the document, ui_timeline_get_clip_frames
samples one clip's source media in a browser, and neither shows the composited
picture — the title over the shot, an animation mid-flight, a transition part
way through, the track order deciding what covers what.
It resolves the scene through @nodetool-ai/timeline/scene
(computeActiveLayers → resolveAnimatedLayerProps), rasterizes the
text/shape/caption layers with the shared draw.ts rules, decodes media with
Mediabunny, and composites with the shared drawTimelineFrame — so a previewed
frame follows the same rules the editor's preview and the video export do.
rasterize.ts is NodeRasterizer's twin, differing only in returning the
@napi-rs/canvas surface instead of an RGBA buffer, because Canvas 2D draws it
rather than uploading it.
Three properties worth keeping:
- No GPU, no ffmpeg, no browser. It imports
@nodetool-ai/timeline/scene, not/render, so TypeGPU never enters this package's graph. That is what lets the capability run wherever the agent runs — the headless kernel, the CLI, a sandboxed CI box with no Vulkan ICD. - Frames are handles, not pixels.
view_imageis the one mechanism that pulls pixels into context, so each frame is persisted withpersistOutputand returned as anasset_id. Three inline base64 frames would sit in every later turn of the conversation whether or not the model looked at them. - What it cannot draw, it names. Color and blur adjustments map onto the
canvas filter; chroma key, vignette and sharpen have no Canvas 2D form and
come back in
effects_not_applied. A layer that drew nothing carries askippedreason — an unrendereddraftclip, an unreadable asset, no decodable frame at that source time — rather than vanishing from the report.
Tests: tests/capabilities-timeline-preview.test.ts, which reads pixels off
the rendered PNGs (layer order, a fade's mid-ramp opacity, text actually drawn)
rather than asserting a call happened.
Drive, Gmail, Docs, Sheets and Calendar tools that authenticate with the access
token from the user's Google sign-in — there is no API key. The Supabase Google
login hands the browser a provider_token, the web app posts it to
POST /api/oauth/google/session, and the server stores it as an
OAuthCredential under provider google. Tools read it back through the
virtual secret key GOOGLE_ACCESS_TOKEN, which getSecret routes to
resolveGoogleAccessToken (refreshing a stale token when GOOGLE_CLIENT_ID and
GOOGLE_CLIENT_SECRET are set).
They are not in BUILTIN_TOOL_CLASSES. A server without a login can never
produce a token, so the chat toolbelt adds them only when
isGoogleWorkspaceEnabled() (@nodetool-ai/config) is true — Supabase auth
mode, or NODETOOL_GOOGLE_WORKSPACE=1.
The fourteen lib.google.* nodes are gone. Each wrapped a call this module
already makes, on the same sign-in token, and the module makes six more no node
ever offered: get one Drive file, get one Gmail message, list labels, create a
spreadsheet, list calendars, delete an event. The capability is the only Google
surface now, and it is two things at once — an agent tool and a guest import.
// Nothing to install — capability modules are mounted by the host, not
// resolved as packs from the catalog.
import { drive_search, gmail_send_message } from "@nodetool-ai/sandbox-nodetool/google";
const { files } = await drive_search({ query: "name contains 'invoice'" });
await gmail_send_message({ to: "a@b.c", subject: "Invoices", body: String(files.length) });import { getGoogleWorkspaceTools } from "@nodetool-ai/agents";
if (isGoogleWorkspaceEnabled()) {
toolbelt.push(...getGoogleWorkspaceTools());
}A missing or revoked credential surfaces as { error } telling the user to sign
in with Google again, rather than throwing — the agent can then pick another
route instead of failing the whole step.
One ladder decides every actionable call. decidePermission
(tools/tool-permissions.ts) reads a mode and a category, gatedCall
(capabilities/invoke.ts) runs the sequence around it (read-class fast path,
mode decision, session allow-set, approval round trip), and gateTools
(capabilities/gate-tools.ts) is the door a Tool walks through it. What
differs between hosts is who answers when the ladder asks.
A host publishes one PermissionGateOptions under
PERMISSION_GATE_CONTEXT_KEY (src/types.ts), and every loop under it reads
that object with gateFromContext instead of building a gate of its own
(invariant I-1). This is the channel RunBudget already travels on, for the
same reason: ProcessingContext.copy() shallow-copies the variable bag, so a
child shares the host's gate object rather than a clone, which is what makes a
mid-turn set_permission_mode and an "allow for this chat" answer reach a loop
that started before them. The contract itself — the key, the types,
decidePermission, headlessGate — lives in @nodetool-ai/runtime
(permission-gate.ts) so the workflow hosts below this package can set a
gate; this package re-exports the names. Every host sets one, so a context
carrying no gate is a bug: gateFromContext logs it at error level and
answers a gate that lets read-class calls through and denies everything else
(mode default, approver deny). A host that means "nobody to ask" says so
with headlessGate(hostName).
| Host | Where its gate comes from | Mode | Approver |
|---|---|---|---|
| Chat turn (web, Telegram) | chat-turn.ts sets chatGate on the turn context |
the thread's live mode, read through a getter so set_permission_mode lands mid-turn |
round trip to the client (requestToolApproval) |
| MCP mount | mcpSessionGate() in mcp-agent-tools.ts — headlessGate("MCP") with execute_code seeded into sessionAllow — and the whole belt wrapped in gateTools, so the direct registrations and tools.<name>() inside a code action meet the same ladder |
auto: the client's user connected this agent deliberately, and an MCP session has no approval UI of its own to prompt through |
nobody: the headless deny. The one standing approval is execute_code, because the client already put that call, code included, in front of its user; an escalation from inside a run (an un-allowlisted Apify actor) has no such answer and is denied |
run_node child |
runSingleNode builds a context of its own, so the turn's gate is passed in as an argument and set on it |
the calling turn's, the same object | the calling turn's client |
AgentNode in a workflow |
genProcess wraps what buildTools returned in gateFromContext(context, "Agent node") |
the host's, or auto when no host set one |
the host's, or the headless deny |
| JS script | js-script-sandbox.ts passes gateFromContext(context, "JS script") to createCapabilityRun |
same | same |
nodetool.code.Code node |
code-node.ts reads gateFromContext(context, "Code node") once and uses it for both doors: createCapabilityRun for the @nodetool-ai/sandbox-nodetool/* imports, gateTools around the belt the bridge calls |
same | same |
| Kernel workflow run | buildWorkspaceExecutionContext (packages/execution/src/service/workflow-workspace.ts) sets headlessGate("kernel workflow run") on the context it builds, and ExecutionSession.create sets the same on a caller's own context when that caller set none (see below) |
auto |
nobody: the headless deny |
| Headless job runner (trigger-driven runs) | packages/websocket/src/headless-job-runner.ts sets headlessGate("headless job runner") on the run context |
auto |
nobody: the headless deny |
nodetool agent run on a TTY |
createCliPermissionGate (packages/cli/src/permission-gate.ts), host name nodetool agent run, set on the run's context |
--permission-mode, defaulting to default |
the terminal: y / n / a prompted on stderr, because stdout carries the result. a answers allow_for_chat, and the name lands in the run's shared sessionAllow |
nodetool agent run with the objective piped in |
the same builder, not interactive | --permission-mode, defaulting to auto |
nobody: headlessGate's deny, its reason printed once per run |
nodetool-chat reading piped stdin (runStdinMode) |
the same builder, host name nodetool-chat, set on each line's context |
--permission-mode, defaulting to auto |
nobody: the headless deny |
nodetool-chat --url |
no gate is built here | — | the server's chat turn, which gates the belt it runs |
nodetool-chat interactive session |
nothing: the Ink session builds its own belt in app.tsx, wraps it in no gateTools, and puts no key on its context |
none | nobody |
A CLI run that cannot prompt keeps the mode it was asked for and changes only
the approver, so a piped --permission-mode plan still blocks what plan mode
blocks. The sessionAllow Set is created once per run and shared by reference,
which is what makes a outlive the call that answered it: nodetool-chat
rebuilds its belt for every input line and consults the same Set. Under
--json the notice and any prompt ride the event stream rather than stderr, so
that stream stays one JSON object per line.
The interactive CLI session is the host A2 left open. Its belt is
RunSubtaskTool plus raw tools, so nothing there meets the ladder. The reason
is the terminal, not the design: the Ink session reads stdin itself
(useInput), so the readline prompt every other CLI path uses cannot share
it, and an approval there needs an Ink component instead. Rather than accept a
mode it would ignore, packages/cli/src/index.ts prints that
--permission-mode does not apply to the interactive session.
Both gated CLI hosts wrap their belt the way chat does. gateTools covers
the belt, and execute_plan is wrapped on its own, because
toolForCapabilityName builds a LazyCapabilityTool whose process calls the
implementation directly: a gate on the delegation run covers what that
implementation invokes, not the tool itself. The delegation primitives
(run_subtask, start_subtask, wait_subtasks, run_search, create_plan)
stay ungated in both hosts, because spawning a child loop has no effect of its
own and the child acts through the belt that is already gated.
The kernel row is a decision, set explicitly. A workflow run is consent:
the user pressed Run on a graph whose nodes list their tools, so an agent loop
inside it runs auto with nobody to ask. buildWorkspaceExecutionContext
says so by setting headlessGate from @nodetool-ai/runtime, and
ExecutionSession.create does the same for a caller that hands in its own
context with no gate on it (nodetool run, the WebSocket job runner, the app
simulator), while a caller whose context already carries a gate — a chat
turn's run_node — keeps it by reference. Setting it at the choke point is
what lets gateFromContext treat an absent gate as a bug and fail closed.
Plan mode already blocks run_node one level above the node. run_node
is classified execute, and decidePermission("plan", "execute") is block,
so a chat in plan mode gets blocked_in_plan_mode from the belt before the
node runs. The gate on the node closes the same hole reached another way: a
set_permission_mode to plan while a node is already running, or any other
host that puts a plan-mode gate on a context. What the child inherits is pinned
by packages/websocket/tests/chat-turn-handler-run-node.test.ts, which asks
gateFromContext from inside the node's own executor.
Control tools and submit_result stay ungated on an AgentNode. They are
graph wiring rather than capabilities: a control tool fires an outgoing control
edge, and submit_result hands the node's structured outputs back to the node.
Both are appended after gateAgentTools has run. The wrap sits at the
genProcess call site rather than inside buildTools because buildTools is
the hook a subclass overrides, and gating inside it would be skipped by any
override that does not call super.
The headless gate denies, and names the host that had nobody to ask.
headlessGate(hostName) runs auto with an approver that resolves "deny"
and logs the refusal. In auto the ladder allows read, write, execute and
external outright, so that approver is reached only by an escalation the ladder
itself raises: admitCodeAction asks before a high-risk execute_code action
in auto, and the Apify actor policy asks before an actor the install has not
allowlisted. Resolving "allow" would grant what those escalations exist to
withhold, and never resolving would hang the run (invariant I-4).
headlessDenialReason is exported so a host that runs headless on purpose can
print the same sentence once at the start instead of once per denied call.
Three construction sites may still build an ungated run.
capabilities/lazy-tool.ts and tools/serp-tool-factory.ts build a Tool the
host gates from outside with gateTools; capabilities/packs.ts reads a
SKILL.md, a read-class call with nothing for the ladder to withhold.
packages/agents/tests/gate-from-context.test.ts walks every
packages/*/src for ungatedCapabilityRun and fails on any file its allowlist
does not cover. It also asserts that the three sites it names by hand were
found, so it cannot pass on a walk that matched nothing. The walk used to stop
at this package, which is why the Code node's ungated mount sat outside it.
There is no plan-approval callback. execute_plan is classified external in
tools/tool-permissions.ts, so running a plan is approved where every other
action is approved — one ladder, one dialog, one mode
(set_permission_mode) — instead of a second round trip the host had to wire
by hand. create_plan produces a plan and stops, so the user sees what will
run before anything does.
A host with nobody to ask fails closed at execute_plan rather than inside the
loop, which is what invariant I-4 asks for.
create_plan decomposes an objective into parallel tasks via
TaskPlanner.planMultiTask(), and execute_plan runs them. Tasks form a DAG —
independent tasks run concurrently.
- Planning: LLM generates a
TaskPlanwith multipleTaskobjects, each withdependsOnarrays - Scheduling:
ParallelTaskExecutorhands the tasks toscheduleDag(utils/dag-scheduler.ts), which starts a task the moment its last dependency settles.TaskExecutorschedules its steps the same way. - Merging:
createDynamicMerge()interleaves the streams and admits a newly-started node into the running merge (utils/merge-generators.ts) - Completion: A task's result reaches shared memory and its terminal
task_updateis emitted before its dependents are released; the stream ends when every task has settled
Scheduling is event-driven, not round-based. The executors used to dispatch in
barrier rounds — start every ready node, wait for the slowest, recompute — so a
plan ran as slow as its slowest sibling in every round, and the cap on rounds
per plan and per task turned depth into failure. Both caps are gone: what
bounds the work is the run budget, maxStepIterations per step, and the permit
pool. A cycle never reaches the scheduler (PlanBuilder rejects it), and a node
nothing could ever run settles failed with unsatisfiable dependency rather
than hanging.
The aggregation step is the finish step's dependency on every other step in the task, not a deferral rule: it runs last because it waits for its siblings, and a sibling that fails blocks it (I-5) instead of aggregating over a hole.
interface TaskPlan {
title: string;
tasks: Task[]; // Multiple tasks forming a DAG
}
interface Task {
id: string;
title: string;
steps: Step[];
dependsOn?: string[]; // Task IDs this depends on ([] = independent)
}
interface Step {
id: string;
instructions: string;
dependsOn: string[]; // Step IDs within this task
tools?: string[]; // Restrict available tools
outputSchema?: string; // JSON schema for step result
}execute_plan takes the tasks as its argument, so a caller that already knows
the decomposition passes it straight in and never calls create_plan. The
shape is the TaskPlan above with snake_case keys (depends_on), validated by
buildPlanFromTasks (tools/plan-builder-tools.ts) before anything runs.
Two bounds are left, both in constants.ts, both read by
parallel-task-executor.ts and task-executor.ts when a caller names none:
DEFAULT_MAX_STEP_ITERATIONS (action rounds per step) and
DEFAULT_MAX_CONCURRENT_AGENTS. The round caps that sat beside them are gone
(see the scheduling note above); the rest of what bounds a run is RunBudget
below.
Nothing resolves a per-run policy object: execute_plan passes the run's
budget and the parent's per-step iteration cap directly.
maxConcurrentAgents bounds one merge numerically (tasks per plan, steps per
task), not the run. The run-level bound is the budget's permit pool below, and
a permit stands for one open provider conversation, not one layer: a task takes
none, each step holds one through branchPool (subagent.ts), and a child a
step or sub-agent spawns runs on its parent's permit while the parent is idle
in the spawning call and borrows from the run pool beyond that. A holder never
queues for a second permit, so nesting cannot deadlock, and one pool bounds
tasks, steps and sub-agents together (run-budget-propagation.test.ts, "bounds
every layer"). start_subtask refuses past MAX_BACKGROUND_SUBTASKS_PER_TURN
(tools/start-subtask-tool.ts) with background_limit_reached; a detached
child still on its parent's permit can overlap the parent's next turn.
Planning draws on the same budget: TaskPlanner takes a budget (falling back
to the context's) and a refused turn stops planning naming the budget's reason.
A run's bounds are one object, created by the host and shared downward, never
re-created by a child. RunBudget carries four:
| Bound | What refuses | Setting |
|---|---|---|
turns |
a turn whose worst case would cross the USD cap | NODETOOL_AGENT_TURN_COST_CAP_USD |
deadline |
any turn or tool call after the wall clock runs out | NODETOOL_AGENT_TURN_DEADLINE_MS |
concurrency |
a permit past the run's pool — tasks, steps, nested executors and sub-agents all draw from it, a holder's own permit counting as one | NODETOOL_AGENT_MAX_CONCURRENCY |
turnCount |
a turn past the run's cumulative total | NODETOOL_AGENT_MAX_TURNS |
Spend admission is reserve-then-commit, because the next call's cost is only
known afterwards: $0.49 spent under a $0.50 cap still admits a $0.30 call.
turns.reserve is the single admission point and records the first reason it
refuses; exhausted is set once, so a run that ran out of money and then ran
out of time stopped for the money.
A reservation is a handle, because the budget is shared. turns.reserve
answers a TurnReservationHandle carrying that turn's own worst case and its
own pricing (or null when it refused), and turns.commit(handle, actualUsd)
settles exactly that one — every other loop's reservation stays outstanding.
One shared counter released whatever was in flight, so a chat turn committing
its turn handed a concurrent sub-agent's headroom back and the cap stopped
holding; a single unpriced flag likewise booked whichever turn committed
first under the other's pricing.
An unpriced model is not a free one. A model the price catalog does not
cover has no worst case to reserve, so it is admitted only while its prompt
fits NODETOOL_AGENT_UNPRICED_TOKEN_CEILING, counted in unpricedTurns, and
leaves spentUsd a declared lower bound — the unpriced convention nodetool costs already follows. With no USD cap configured there is nothing for the
ceiling to protect and it does not apply; the run stays bounded by its deadline
and turn count.
How a child reserves against its parent. The budget rides
CapabilityRun.budget and SubAgentToolRuntime.budget, and subAgentRuntime()
(capabilities/agents.ts) is the one seam where it joins the runtime, so every
delegation capability inherits it from a single place. runSubAgent hands that
object — never a new one — to its CodeActExecutor; execute_plan carries it
into ParallelTaskExecutor, which forwards it to every TaskExecutor and on to
every CodeAct step. It also rides RUN_BUDGET_CONTEXT_KEY on the context, which
is how a loop the host never constructs finds it: an AgentNode started through
run_node, or a JS script. ProcessingContext.copy() shallow-copies the
variable bag, so a child context shares the same budget object rather than a
clone — that sharing is what makes a cap a run total.
Permits are taken once per branch. A run-total semaphore deadlocks if a
holder queues for a second permit, which nested merges do by construction:
ParallelTaskExecutor holds permits for tasks whose TaskExecutors then queue
for step permits. acquireRunSlot is therefore re-entrant on a context flag,
and each DAG executor holds one slot for the length of its run.
A budget stop is visible, never silent. generateLoop used to return, so
a consumer could not tell a refused turn from the model ending its turn. It
yields one ProviderStop as its final item — budget, deadline,
iterations, or aborted — and a turn the model ends on its own yields none.
Every consumer surfaces it: a CodeAct step fails naming the reason the budget
recorded, and AgentNode raises a node error. classifyProviderStream
(llm-nodes/agent-utils.ts) yields a stop event for it; before that branch
existed the item fell through its chunk/tool-call/message chain and vanished.
Absent means unbudgeted, not exhausted: a kernel workflow run with no host budget behaves exactly as it did before.
A step that fails sets step.failed + step.error and leaves completed
false, and its step_result carries the protocol-level error field. Nothing
downstream may treat a failure as a satisfied dependency: the scheduler walks
the failed node's transitive dependents and settles each as failed, naming the
dependency directly above it, and execute_plan answers a plan whose every
task failed with error: "plan_failed" and the per-task failures rather than
compiling a deliverable out of nothing; it does not throw. The same holds one
level up — a failed task blocks its dependent tasks — and a step or task the
run's signal cut short settles once as "Step aborted" / "Task aborted", never
as "dependency failed", rather than being left looking like it is still
running.
The action space of the step loop, and the only one. Each step acts by writing
JavaScript that runs in the QuickJS sandbox with the toolbelt exposed as
imports from @nodetool-ai/sandbox-nodetool/<namespace>, finish(result) for
host-validated completion, and memory (nodetool.memory.*) for results
a later action or turn needs — there is no cross-action variable bag. The
prompt tells the model to record each generate/speak result (an asset:// uri)
with nodetool.memory.save and reuse it; local variables die with the action,
and return is the observation only. A step action runs with no guest
fetch and no secret scope: network and secrets are reached only through the
gated capabilities, the same rule the chat session's actions run under. Design
and the research it follows (CodeAct, ICML 2024): docs/codeact-design.md.
CodeActExecutorkeeps the message contract, memory writes, and failure semantics the step loop has always had — consumers work unchanged. Bridged tool calls surface astool_call_updateevents (idscodeact_<n>).- The core set goes top level. Every NodeTool tool that mirrors a Claude Agent
SDK built-in (
CORE_TOOL_NAMESin@nodetool-ai/runtime— the file set,glob/grep,web_search,browser/http_request/download_file,todo_write,run_subtask) is offered to the provider as an ordinary tool next toexecute_code, for every provider. Those are the shapes models are trained on, so a tool call beats a sandbox round trip that only forwards one. They stay on the belt —nodetool.web,nodetool.agentsand hand-written fan-out call them from code — but the prompt documents them once, under "Direct tools", instead of as a catalog signature.splitCoreTools/buildCoreProviderToolsinsrc/codeact/tool-api.ts. - Discovery goes top level with it.
DISCOVERY_TOOL_NAMES(find_model,list_models,list_provider_models,search_nodes,get_node_info,list_nodes) answers which providers, models and node types this install has — one question with one answer, and a wrong answer is a hallucinated model id that fails at generation time, after the run was paid for. The two sets stay apart because only the core set has SDK built-ins behind it:DIRECT_TOOL_NAMESis their union and is what the four offer sites read (splitCoreTools, the CLI turn, the websocket runner, and the MCP mount), whileSDK_NATIVE_TOOL_REPLACEMENTSstill readsCORE_TOOL_NAMESalone.nodetool.models.pickandnodetool.nodes.searchare unchanged — the belt keeps every tool, so an action still composes them. - The MCP mount subtracts. It offers
DIRECT_TOOL_NAMESminus every key ofSDK_NATIVE_TOOL_REPLACEMENTS, because an MCP client (Claude Code, ChatGPT) is the host agent that table describes: offering NodeTool's workspace-scopedread_filebeside the client's own would put two tools of one name and two different roots in front of one model. What survives has no host equivalent — discovery, the server-side reach that runs behind NodeTool's SSRF guard and secrets (browser,http_request,download_file,list_directory), andrun_subtask, whose child gets the NodeTool belt rather than the client's. It is abelt.filter, so a session missing an injected dependency (no node registry, say) advertises no node tools instead of a tool that cannot run. Before this the mount registered onlyexecute_codeandview_image: everything was still reachable as an import inside an action, but every discovery question cost a sandbox round trip, which is the tax the direct set exists to remove. - On
claude_agent_sdkthe built-in wins outright. The provider drops every toolSDK_NATIVE_TOOL_REPLACEMENTSmaps (read_file→Read,write_file→Write,edit_file→Edit,glob→Glob,grep→Grep,web_search→WebSearch,todo_write→TodoWrite) from its MCP toolset and lets the SDK's own tool serve the call. The path-scoped five are substituted only when the caller passes aworkspaceDir, which becomes the sessioncwd— without it the SDK would resolve paths outside the run's workspace, so NodeTool's contained versions stay.list_directory,browser,http_request,download_fileandrun_subtaskare never substituted: no built-in covers what they do (Taskwould hand the child SDK tools, not the NodeTool belt). Seepackages/runtime/src/providers/core-tools.ts. - Progressive disclosure: resident tools (
CODEACT_RESIDENT_TOOL_NAMES— the search family incl.web_search/search_nodes/run_search/asset_search/grep/glob, the Claude-agent file set (read_file/write_file/edit_file/list_directory), browser, HTTP, memory,run_subtask) are documented in full; pastCODEACT_DEFER_THRESHOLDtools, the rest is name-only in the prompt and discovered in-sandbox withawait nodetool.searchTools("query")(ToolSearch grammar). All tools stay callable either way. Discovery lives on the object model and nowhere else: the baresearchTools()global is gone, because a model that knowsnodetool.*looked for it there and burned two rounds finding out it was elsewhere. - Imports are discoverable too:
nodetool.packs.list()reports every installed pack and whether this session allows it,modules(pack)the specifiers it declares,exports(specifier)the function names one module exports, anddocs(specifier)the pack's SKILL.md. A pack installed but off the allowlist is listed asallowed: falserather than hidden — hiding it teaches the model the pack does not exist. The data reaches the guest as a tool (list_sandbox_packages,capabilities/packs.ts), which is the only path the object model has: it owns no host bridge. Exports are exact for host modules (SANDBOX_HOST_MODULES), WASM modules (the manifest contract) and platform modules (the capability registry); for a guest JS module they are read off its ownexportstatements with acorn, and a module that re-exports withexport *answerscomplete: falseinstead of a short list. - Every step executor is one:
TaskExecutor,ParallelTaskExecutor,run_subtask, andrun_searchall constructCodeActExecutor.StepExecutor— the older one-JSON-tool-call loop — is no longer exported; two callers keep it because they are one-shot structured verdicts on a fail-closed path where a sandbox error would only add a failure mode:SupervisorAgentand the app-build spec stage. - Chat turns run in it too: the websocket runner swaps the toolbelt
for
execute_code(+ the core set, +view_image) viacreateChatCodeActSession(src/codeact/chat-codeact.ts), which routes every imported belt name to the chat runner's own tool router instead ofbuildToolBridge— permission gating and client (ui_*) round-trips stay where they are. When the belt carries theui_*workflow document tools, actions also get the graph object model (src/codeact/graph-model.ts):openWorkflow()returns a model whose synchronous mutators queue ops against a local mirror andcommit()replays them through the sameui_*contract. The CLI's local (no-server) turn runs the same session —execute_code, the core tools andview_imageare whatprocessChatsees, wired inpackages/cli/src/chat-codeact.ts. - Both executors also load the
nodetoolobject model (src/codeact/nodetool-api.ts): the platform as objects instead of raw imports —nodetool.workflows(list/get/run/start/debug/validate/ create/open),nodetool.batch(items, fn, {concurrency})for bounded fan-out (run a workflow once per CSV row),nodetool.models(pick(capability)resolves one ranked model;find/listfor the long form;forProvider(provider)for one provider's own catalog), andnodetool.media(generateImage/editImage/generateVideo/animateImage/speak/transcribe/embedplus the judge loopcritique/compare/scoreAdherenceandunderstandVideo(video, prompt, model), which hands a whole clip to a model that reads video and answers as text — each taking a pick/find result or"provider/model_id"; plus the host binariesffprobe(path),ffmpeg(args)anddownloadVideo(url, outputFile), whose argv is confined to the workspace and to local-file inputs bysrc/host-binary-guard.tsand bounded on wall clock, captured output, artifact size and concurrency bysrc/host-binaries.ts),nodetool.nodes(search/info/list— the graph builder's discovery half),nodetool.documents(convert, PDF text/tables, markdown↔pdf),nodetool.apps(build/debug),nodetool.agents(run(prompt)spawns arun_subtaskchild with a fresh context; fan out vianodetool.batch(prompts, (p) => nodetool.agents.run(p))), the single-node harness onnodetool.nodes.run(type, inputs),nodetool.web(the outside world:search(query, {provider}),newsandimagesare all one routedweb_searchwith asearch_type, which picks the first configured backend host-side;providerpins one:"default","serpapi","dataforseo","brave","apify","openai","google"— plusbrowse(url),fetch(url),download,screenshot),nodetool.memory(save/list/search/update/removeovermemory_*),nodetool.shared(list/read/publishoverlist_shared/read_shared/share_result— the run's own scratchpad, beside thread-scopednodetool.memory),nodetool.threads(list/get/last/messageoverlist_threads/get_thread/get_message— the chat history itself, read-only),nodetool.style(profile/record),nodetool.email(search/archive/label), plusassets(list/search/images/get/save/read),jobs(withwait(id, {timeoutMs, pollMs})polling a background job to settlement),collections(full RAG loop:index/indexBatch/search/hybridSearch/query),timelines,sketches,scripts, andstoryboards.workflowsalso carriesresolve(sessionId, escalationId, action)for interactive-run escalations andexample("<package>/<name>")feedingcopyFrom(list({workflow_type: "example"})enumerates the shipped examples). Every method wraps a belt tool, so gating and routing are untouched; a method whose backing tool is missing throws naming the tool, and the prompt section documents only the namespaces the belt can serve (buildNodetoolApiPromptSection). One surface per capability: tools the object model wraps (nodetoolApiCoveredToolNames, plusGRAPH_MODEL_TOOL_NAMESwhen the graph model loads) are filtered out of the prompt's tool catalog — they stay callable through the bridge and findable vianodetool.searchTools(), but thenodetool.*form is the only documented one. Workspace files are the deliberate exception: they are not wrapped, because the sandbox's ownworkspace.*API is in-process and costs no tool call — the action contract steers there. - The belt carries only what a model cannot write itself. The pure-computation
tools (
calculate,geometry,trigonometry,statistics,unit_conversion) were deleted outright, MCP included, and so were the code toolsrun_codeandjs—execute_codeis the code surface, and a second one only invited the model to run code without the sandbox'snodetool.*API. The nine provider-specific duplicates went next. The media four —image_generation,openai_image_generation,google_image_generation,openai_text_to_speech— were deleted, becausenodetool.mediacovers them through the provider-agnosticgenerate_image/generate_speech. The search five —openai_web_search,google_grounded_search,dataforseo_search,dataforseo_news,dataforseo_images— were never duplicates at all: they are the backends the singleweb_searchcapability routes to, so they became plain functions with no wire name (backendstill pins one).google_newsandgoogle_imageslater folded into it too, assearch_type: "news" | "images"— three wire names and three routing tables for one question with a parameter on it, which also left Brave and Apify reachable from no tool at all. Image search split back out into its ownimage_searchcapability (web.ts'simageSearchImpliswebSearchImplwithsearch_typepinned to"images"— one routing table, a second name): a model reaching for an image search reliably guesses a name likeimagesbefore it guesses a parameter on a text-search call, and a guess against the "web" module's export list is exactly whatsynonymHintSentenceincodeact/capability-modules.tscorrects. News stayed asearch_type, since nothing observed a model reaching for anews_searchimport the same way. Every host therefore assembles the one beltgetBuiltinTools()(src/tools/builtin-tools.ts) returns; a saved AgentNode naming a retired tool resolves to its replacement throughRETIRED_TOOL_NAMESin@nodetool-ai/llm-nodes. - Authoring a graph is a package, not a builder.
nodetool.graph()— a builder taking every node type as a free-form string — is gone; a session authors with@nodetool-ai/sandbox-dsl, which ships one generated function per node type with that node's real inputs, one module per namespace. A type the catalog does not have has no export, so a hallucinated type fails at import instead of surviving into a graph nobody checked.withGraphDslPackage(src/codeact/graph-dsl-package.ts) is the wiring: it puts the pack on the session allowlist when the belt carriescreate_workflow,validate_workflowandrun_workflowand the catalog serves the pack, so a machine without it installed is never told to import it.CodeActExecutorandcreateChatCodeActSessionboth call it, and the answer also gatesGRAPH_DSL_PROMPT_SECTION. Consent is per pack: an allowlist entry covers that specifier's subpaths, which is what makes seventy-two namespace modules one prompt line. Tests:tests/codeact-graph-dsl.test.ts. - Eval suite
codeactscores the executor on offline instrumented cases:nodetool eval codeact -p <p> -m <m>. Beyond the four toy-toolbelt cases it covers the fullnodetool.*API surface over two deterministic in-memory worlds (src/evals/codeact-api-core.ts,codeact-api-surfaces.ts) whose fakes are named like real belt tools so the object-model prelude lights up.tests/codeact-api-coverage.test.tsfails when a namespace loses its last case. Further cases (src/evals/codeact-sandbox-pack-cases.ts) cover sandbox packages: importing a pack the session allows and computing from what it parses, reading a pack's SKILL.md throughget_sandbox_package_docsinstead of guessing its API, reading the catalog instead of guessing what is installed, reporting a pack as unavailable rather than working around it, and using two packs in one action. A case names its allowlist insandboxPackages, and the runner puts a catalog over the shipped packs (shippedPackCatalog()) on the context: the three host packs plus@nodetool-ai/sandbox-dsland@nodetool-ai/sandbox-flow, whose modules are guest JavaScript the packs ship outright — a pack with an npm dependency would need@nodetool-ai/sandbox-compiler, which this package does not depend on.requiredSessionToolsscores tools the executor adds rather than the case, which the recorder cannot see.scripts/dump-codeact-run.ts <case> <provider> <model>replays one case live and writes every action's code tonodetool-debug/— the tool to reach for before touching the action-contract prompt. Run it withIS_SANDBOX=1 … --max-iterations 40. The residual misses rotate with sampling, so judge a prompt change on the per-action dumps, not on one run's pass count. - Tests:
tests/codeact-executor.test.ts,tests/codeact-eval.test.ts,tests/chat-codeact.test.ts,tests/nodetool-api.test.tsandtests/nodetool-api-*.test.ts(scripted provider, real sandbox, no network).
The chat's Plan permission mode blocks every mutating capability, so a
multi-step request used to come back as prose. create_plan (in the agents
capability module) runs TaskPlanner.planMultiTask over the objective,
forwards the planning_update / task_update events into the thread — the web
already renders both — and returns the plan's shape. It executes nothing: the
planner produces a plan, and the turn ends with it on screen.
It is offered only in plan mode, and there it joins DIRECT_TOOL_NAMES so
the model can call it outright; a deliverable reachable only by writing a
sandbox action to import it is one the model does not reach for. The planner
sees the parent belt minus create_plan itself, so steps route to real tool
names and no plan contains "call create_plan".
execute_plan is the other half: it takes that plan back and runs it on
ParallelTaskExecutor. The plan travels inline — title plus the tasks
array the model copies out of
create_plan's result. There is no plan store and no plan id: passing the plan
itself is what survives the mode switch, keeps what runs identical to what the
user is looking at, and lets "run it, but drop task 3" be expressible. Plans
are a few hundred tokens.
The plan is checked before anything runs, by PlanBuilder — one authority for
what a plan may be, so a plan create_plan produced cannot be rejected here.
buildPlanFromTasks (tools/plan-builder-tools.ts) sorts the tasks so every
dependency precedes its dependents (a hand-edited plan need not arrive that
way) and feeds them through addTask/finish. Kahn's residue is exactly the
tasks in a cycle, so a cycle is reported by name rather than as a task that was
"not added yet". A rejection returns invalid_plan with one issue per
offender and runs nothing.
Every message the executor yields is forwarded verbatim — the task_update
events are what the thread and the sidebar render, so re-summarizing them would
leave the user watching nothing until the end. The call returns each task's
completed/failed (with the step and error behind a failure), a results
map keyed by task id, and beside it a steps map of per-step results, so a
caller can read what one step produced without unpacking its task. A plan
whose every task failed comes back as error: "plan_failed" with those
failures, not as a throw. The step results also stay in the run's shared
memory under task:<id>, which the description points at so the model reads
one back with read_shared instead of redoing the work.
execute_plan is classified external: running a plan is not one action but
every action in it. That is what blocks it in plan mode — the permission gate
is the confirmation, and there is no second approval step — and asks once
everywhere else. Each step's own tool calls stay gated inside its child loop.
The steps see the parent belt minus both plan capabilities: a step that
re-plans or re-runs the plan it belongs to is a loop.
In chat-turn.ts it sits on the belt in every mode, gated, so a call in
plan mode answers blocked_in_plan_mode rather than "unknown tool"; it joins
DIRECT_TOOL_NAMES in every mode except plan.
The one place that knows how to spawn, stream, and settle a child agent. A
sub-agent is an async generator of ProcessingMessage events whose return
value is how the run settled — CodeAct is the default producer, but anything
with that shape (a future reviewer, a researcher) streams through the same
pipe and nests in the UI the same way.
| Primitive | Does |
|---|---|
runSubAgent(opts) |
One CodeAct child loop: single-step task, optional outputSchema (structured via finish(), prose otherwise), yields events, returns SubAgentOutcome — never throws for run failures |
settleStepResult(sr, {hasOutputSchema}) |
The unified failure detection: top-level step_result.error, the sole-key {error} payload a dying step reports, and (schemaless only) any string error property |
forwardSubAgentStream(gen, opts) |
Drives any sub-agent generator: tags events (parent_tool_call_id, subtask_depth), forwards without letting a broken forwarder kill the child, honors an abort signal between events |
enterSubAgentDepth(ctx, maxDepth) |
The shared recursion gate over SUBTASK_DEPTH_KEY: refuses past the cap, else returns a copied context with the depth bumped |
SubAgentTool |
Base class for tools that expose a sub-agent to a parent model — subclasses declare the tool surface, translate params into a SubAgentToolRun, and pick the child toolset; the base owns depth gate, streaming, tagging, settlement |
Every spawn site goes through it: RunSubtaskTool (inherits the full parent
belt, stitches itself in for recursion), RunSearchTool (read-only
allowlist, breadth-scaled iteration budget), and StartSubtaskTool (same
child but returns a subtask_id receipt immediately so the parent keeps
its turn) are thin SubAgentTool subclasses. WaitSubtasksTool reads the
same per-turn BackgroundSubtaskRegistry (src/background-subtasks.ts) that
the background pump settles — wait_subtasks({ids?, timeout_ms?}) blocks
until every requested record left "running" (or on timeout/abort, which
return current statuses instead of throwing). The registry lives on
SubAgentToolRuntime.background, one per chat turn, built by the host
(websocket runner, CLI); a host with no registry refuses by name. A
background subtask's events still stream to the UI tagged with
parent_tool_call_id/subtask_depth, and nesting depth is still gated
by enterSubAgentDepth. In CodeAct: await nodetool.agents.start(prompt)
await nodetool.agents.wait({ids, timeoutMs})— fan out while you keep working. A generator that is not a CodeAct child streams throughforwardSubAgentStreamdirectly. A new delegation tool should be another subclass, not another copy of the machinery.
Tests: tests/subagent.test.ts (pure-function coverage), plus the spawn-site
suites (tests/run-subtask-tool.test.ts, tests/run-search-tool.test.ts,
tests/background-subtasks.test.ts for start/wait and the registry,
tests/capabilities-agents.test.ts through the capability seam) which
exercise the core end-to-end.
There is no script mode. It was a third planning mode beside TaskPlan and the
graph planner: ScriptPlanner had the LLM author one JavaScript orchestration
script and ScriptRunner executed it in the sandbox, spawning a sub-agent per
agent() call. CodeAct made it redundant — a step already acts by writing
JavaScript in that same sandbox, so the loops, budget-scaled fan-out, dedup
between rounds and early exits a script expressed are ordinary control flow
inside an execute_code action:
| Script primitive | CodeAct equivalent |
|---|---|
await agent(prompt, opts?) |
await nodetool.agents.run(prompt) (a run_subtask child) |
await agent.start(prompt) + await agent.wait({ids}) |
await nodetool.agents.start(prompt) + await nodetool.agents.wait({ids}) — background spawn, collect later |
await parallel(thunks) |
await Promise.all(...), or nodetool.batch for a bound |
await pipeline(items, ...stages) |
nodetool.batch(items, async (item) => …) |
log(message) |
console.log(message) |
budget |
the run's own AgentPolicy bounds, enforced host-side |
inputs |
the step's inputs, read from context.memory via read_shared |
The difference that mattered — one authored artifact reviewed before anything
ran — is not lost: a plan still goes through the approval gate, and an action's
code is visible in the execute_code call. What is gone is the second sandbox
API, the second planner prompt, and the second set of budget knobs
(maxAgentCalls).
authorGraph(objective, opts) is how a graph gets written. The app-build plan
stage calls it once per operation (app-build/build.ts), and the
graph-planner and graph-e2e eval suites drive it directly. It streams
ProcessingMessages and returns the graph.
It runs no loop of its own. runSubAgent drives one CodeAct child whose
allowlist carries @nodetool-ai/sandbox-dsl, so the child authors with the
typed pack — one generated function per node type — instead of the untyped
node(type, props) program the retired one-shot planner took. The belt is
discovery
(search_nodes, get_node_info, list_nodes), validate_workflow, the
Code-body harness (validate_code, run_code, test_code) and find_model
when providers are configured. The graph comes back through finish() against
a {nodes, edges} output schema: the object is already in the sandbox, so
handing it over costs no tokens.
The prompt is assembled once. renderWorkflowAuthoringKnowledge()
(prompts/workflow-authoring-knowledge.ts) is the preamble — which node to
reach for, how agent steps and if_ behave, what a Code node is — and the
DSL mechanics are NOT repeated there: CodeActExecutor renders
GRAPH_DSL_PROMPT_SECTION itself once the pack is on the allowlist.
There is no post-hoc Code-node refinement pass. A whole-graph one-shot had no way to iterate on a Code body it wrote as a string literal, which is what such a pass existed to fix; a CodeAct child validates and re-runs a body inside the same loop. Without a catalog serving the pack the run fails immediately, naming the pack, rather than spending a provider turn on an import that cannot resolve.
Tests: tests/author-graph.test.ts (brief, preamble, belt, plus one scripted
provider driving a real sandbox action over the real shipped pack).
evaluateGraphDsl runs a free-form graph program in the QuickJS sandbox (no
host access) and returns {nodes, edges}. It is the older, untyped authoring
shape — node(type, properties) creates a node, passing ref.output(slot?) as
a property value becomes an edge, and the program ends with return graph();:
const prompt = node("nodetool.input.StringInput", { name: "prompt" });
const image = node("nodetool.image.TextToImage", {
prompt: prompt.output(),
model: { provider: "fal_ai", id: "fal-ai/flux/schnell" }
});
node("nodetool.output.Output", { name: "image", value: image.output() });
return graph();Nothing authors this way any more — authorGraph writes typed calls against
@nodetool-ai/sandbox-dsl instead — but validate_workflow still accepts a
program in this shape, so the evaluator and the wiring core behind it
(src/graph-dsl-core.ts, shared with the pack's guest builder) stay.
GraphBuilder (src/graph-builder.ts) is what loads either shape into a
graph and validates it structurally, alongside node-sdk's validateGraph.
Tests: tests/graph-dsl.test.ts, tests/dsl-handle-interpolation.test.ts,
tests/dsl-workflow-authoring.test.ts.
src/evals/ carries a provider-agnostic evaluation harness for graph
authoring: GRAPH_PLANNER_EVAL_CASES (objectives + structural expectations —
input wiring, node-family patterns, branch handles, reachability, prompt text,
no provider-locked nodes) and runGraphPlannerEval, which drives
authorGraph (metrics per case: accepted, score, authoring rounds, tool
calls, duration, cost; aggregate: success rate, one-shot rate, averages). Run it
against any registered provider:
Two metrics were mapped when the suite moved off the retired one-shot planner:
authoring rounds replaces submit rounds and counts execute_code actions,
so a one-shot is a graph delivered in the first action; attempts is gone,
because authorGraph has no outer retry loop — a repair is another authoring
round.
npm run dev:nodetool -- eval graph-planner --list
npm run dev:nodetool -- eval graph-planner -p anthropic -m claude-sonnet-5
npm run dev:nodetool -- eval graph-planner -p ollama -m qwen-3.5:4b --cases summarize
npm run dev:nodetool -- eval graph-planner -p openai -m gpt-5.4-mini --json --out report.json
npm run dev:nodetool -- eval graph-planner -p anthropic -m ... --min-success 0.8 # floorWhere the suites run. On request. .github/workflows/agent-eval.yml
(workflow_dispatch only) runs the suites that need a provider, plus jtbd,
against inputs for suites/provider/model/cases and a floor override, uploads
each suite's JSON, and renders one table. Nothing here gates a merge: a model
run costs money and carries run-to-run variance, so it is a tool a maintainer
starts after changing the planner, the CodeAct loop, the sub-agent path or a
ui_* contract. The free half is a local command — nodetool eval <suite> --keyless runs the cases that need no key and no network, and --list marks
them.
Harness tests (scripted provider, no network): tests/graph-planner-eval.test.ts.
The graph-planner suite stops at the graph: it scores structure, which says
nothing about whether the workflow does what was asked. src/evals/graph-e2e- {cases,eval}.ts closes that loop — every case runs three phases and only counts
as a success when all three hold:
- plan —
authorGraph()produces a graph, scored structurally by the samecheckExpectationsthe graph-planner suite uses. - execute —
applyRunPolicy(src/run-policy.ts) stamps the run's provider/model onto the planner's Agent nodes (the planner leaves them model-less on purpose — the run owns that choice — so an unstamped graph dies on "Select a model"). This suite is its only importer, and the reason the function outlived the retiredAgentgraph branch, then the graph runs for real with the case's inputs as run params, through a caller-suppliedGraphRunner. The runner is injected so this package needs no execution dependency and the harness tests can drive scripted runs with no kernel; the CLI wires the real one overExecutionSession(packages/cli/src/evals/graph-runner.ts). - judge — deterministic output checks (an output by name exists, is
non-empty, matches/doesn't match a literal) plus an LLM judge
(
src/evals/goal-judge.ts) that reads the case's goal statement and the actual outputs and answers{achieved, score, reasoning}as plain JSON. A regex cannot tell a real German translation from the English echoed back; the judge can. A provider failure or unparseable answer is reported as a judge error, never as a pass.
Metrics per case: planned, executed, goalAchieved, score, submit rounds, node/
edge counts, plan and run duration, cost, plus the outputs themselves.
Aggregate: end-to-end success rate (the --min-success gate), plan rate,
execution rate, mean score. Cases whose graph needs a real model
(needsModelProviders) skip without configured providers; the two deterministic
cases (concat, arithmetic) run anywhere and use skipJudge, since their
outputs are pinned exactly by pattern.
Each case costs inference twice — the run, then the judge — so it is the most
expensive suite here. A full pass on claude_agent_sdk/sonnet runs ~$0.07.
npm run dev:nodetool -- eval graph-e2e --list
npm run dev:nodetool -- eval graph-e2e -p anthropic -m claude-sonnet-5
npm run dev:nodetool -- eval graph-e2e -p openai -m gpt-5.4-mini --cases concat,arithmetic
npm run dev:nodetool -- eval graph-e2e -p anthropic -m ... --timeout 600000 --min-success 0.8Harness tests (scripted provider, fake runner, no network):
tests/graph-e2e-eval.test.ts.
src/evals/code-gen-{cases,eval}.ts drives the real CodePlanner over eight
cases — one per authoring shape the feature targets (reshape, merge/join,
compute, extract/parse, split, format, validate, seed) — and scores each
accepted submit_code submission structurally: declared outputs present and
typed, inputs limited to the slots the dialog offered, the destination-handle
case's expected output present with the right type, every declared output
assigned on every visible return path (analyzeGeneratedCode), no state/
yield when nobody asked, and no name that is neither sandbox API
(unknownApiReferences) nor bound by the code itself (collectBoundNames).
Acceptance is reported twice: first-pass (accepted on round 1, before the
tool fed anything back) and post-repair (accepted at all within the round
cap). --min-success gates on post-repair.
npm run dev:nodetool -- eval code-gen --list
npm run dev:nodetool -- eval code-gen -p anthropic -m claude-sonnet-5
npm run dev:nodetool -- eval code-gen -p openai -m gpt-5.4-mini --min-success 0.9Harness tests (scripted provider, no network): tests/code-gen-eval.test.ts.
The graph-planner suite covers graph mode. The task planner has a suite of its own, scoring the plan statically — nothing is executed.
task-planner runs TaskPlanner.planMultiTask and scores the committed
TaskPlan. PlanBuilder already rejects structurally broken plans (duplicate
step ids, dangling deps, cycles), so anything that comes back is valid by
construction; what it cannot judge is quality, and that is the suite:
parallel width, decomposition proportional to the objective, real dependencies
modelled as dependencies, tool routing (run_python for arithmetic, not a
reasoning step), the step-id prefix convention, and the prompt's hard rule that
final synthesis belongs to the loop that ran the plan, not to an "assemble"
task. Metrics per
case: tasks, steps, parallel width, critical-path depth, planner tool calls,
rejected add_task/finish_plan calls; aggregate adds a clean rate — the
fraction of plans built without a single rejected call.
Cases + expectations live in src/evals/task-planner-cases.ts, the runner in
src/evals/task-planner-eval.ts. It offers a never-executed tool library
(src/evals/planner-tools.ts: web_search, fetch_page, read_file,
write_file, run_python, generate_image) so the planner has something
concrete to route work to.
npm run dev:nodetool -- eval task-planner --list
npm run dev:nodetool -- eval task-planner -p anthropic -m claude-sonnet-5
IS_SANDBOX=1 npm run dev:nodetool -- eval task-planner -p claude_agent_sdk -m sonnet --no-find-modelHarness tests (scripted provider, no network):
tests/task-planner-eval.test.ts.
Cost reads $0 here under claude_agent_sdk. The planner aborts the
provider loop from inside the accepting tool (finish_plan), and the SDK only
reports token usage on its terminal result message — which a cancelled query
never emits. The run is not free; the usage is simply unobservable. Score,
timing, and call counts are unaffected.
Where the tool-loop suites score a model driving one flat tool surface, this
suite scores RunSubtaskTool — the primitive that decomposes work by spawning
a child agent that inherits the parent's toolset.
The harness drives a real CodeActExecutor equipped with RunSubtaskTool plus
a library of instrumented tools. Each tool records every call together with the
SUBTASK_DEPTH_KEY it read from its context — 0 for a parent-level call, >= 1
for one made inside a subtask. The same tool instances serve both levels, so
that field is the ground truth for who ran what, and the scorer can separate a
parent that delegated from one that did the job itself.
Scoring is structural (checkSubtaskExpectations): required parent and child
tools, forbidden tools, subtask-count and depth bounds, no failed subtasks,
required store keys, and answer substrings — never an exact transcript, so many
valid delegations pass. Metrics per case: expectation score, subtasks spawned,
deepest sub-agent level, tool calls at any depth, duration, cost. The aggregate
reports success rate (accepted over non-skipped), mean score, average
subtasks, and total cost; --min-success gates on the success rate.
The seven cases cover one tool per delegation (delegate-compute,
delegate-read-write, delegate-lookup, delegate-transform), fan-out
(parallel-subtasks), a child whose tool fails so the error must surface
rather than be swallowed (error-propagation), and one objective that
exercises every inherited tool (all-tools).
Cases and the instrumented tool library live in src/evals/subtask-cases.ts,
the runner in src/evals/subtask-eval.ts. The parent step and each subtask
share a turn cap of 16 unless --max-iterations says otherwise.
npm run dev:nodetool -- eval subtask --list
npm run dev:nodetool -- eval subtask -p anthropic -m claude-sonnet-5
npm run dev:nodetool -- eval subtask -p openai -m gpt-5.4-mini --cases delegate-compute,all-toolsHarness tests (scripted provider, no network): tests/subtask-eval.test.ts.
Where the graph-planner eval measures graph authoring, the tool-loop harness
measures the incremental, multi-turn tool-calling flow the browser UI exposes.
A real provider is handed the
frontend tool contract (names/descriptions/Zod schemas mirrored from
web/src/lib/tools/builtin/*) and drives it against a headless bridge —
a node-side fake that holds the same state shape and applies the same
mutations, with no browser. runToolLoopEval (src/evals/tool-loop-eval.ts)
is generic over the surface: a case supplies a createBridge factory
(HeadlessSurfaceBridge<TFinal> — { tools, finalState }) plus structural
expectations, and the runner reports the same metrics as graph-planner
(accepted, score, tool calls, duration, cost). Scoring is structural
(checkToolLoopExpectations: required/forbidden tools, ordering, final-state
predicates, tool-call budgets, no-error-results) — never an exact transcript,
so many valid tool orderings pass.
Checks carry a severity, and the score weighs them by it (3/2/1 for
critical/standard/advisory, scoreToolLoopChecks). Whether the required
tools were called, what the final state looks like, and every escalation check
are critical; ordering and no-error-results are standard; the tool-call
budgets are advisory. A run that fails any critical check is additionally
capped at CRITICAL_FAILURE_SCORE_CAP (0.5).
The flat pass-fraction this replaced made scores non-comparable. A live sonnet
run of confirm-before-delete deleted the dead branch without ever asking —
the one behavior that case exists to measure — and scored 0.62, because the
graph it produced satisfied every state predicate. The same run of
escalate-missing-capability escalated correctly, built the fallback the user
described, and scored 0.92, docked only for exceeding a call budget. Under
weighting the first is capped at 0.5 and the second lands near 0.97, which is
the ordering the numbers should have had. criticalFailures per case makes it
visible without reading the check list, and the text report prefixes those
failures with [critical].
Severity also decides the gated metric. A case is a success only when the
loop completed and no critical check failed, and successRate — what
--min-success reads — counts those. "The loop ran to a stop without a
provider error" is reported alongside as completionRate: it is a liveness
signal, not a result, and a model that called zero tools scores 100% on it.
Eleven suites are registered:
| Suite | Tools | Bridge (src/evals/) |
|---|---|---|
tool-loop |
ui_* graph editor |
tool-loop-bridge.ts |
workflow-escalation |
ui_* graph editor + ask_user |
tool-loop-bridge.ts + escalation.ts |
script-tools |
ui_script_* |
surfaces/script.ts |
jsscript-tools |
ui_jsscript_* |
surfaces/js-script.ts |
sketch-tools |
ui_sketch_* |
surfaces/sketch.ts |
timeline-tools |
ui_timeline_* |
surfaces/timeline.ts |
storyboard-tools |
ui_storyboard_* |
surfaces/storyboard.ts |
model3d-tools |
ui_3d_* |
surfaces/model3d.ts |
app-tools |
ui_app_* App Builder |
surfaces/app.ts |
memory-tools |
memory_* / asset_* |
surfaces/memory.ts |
creative-pipeline |
the three creative surfaces, composed, plus ui_brief_* / ui_review_* |
surfaces/creative-pipeline.ts |
creative-pipeline is the long-horizon suite: one commission carried through
brief → ideation → sketch → storyboard → cut → review, scoring the seams
rather than any one surface. It composes the real sketch, storyboard and
timeline bridges instead of reimplementing them, so it cannot drift from the
three suites that already cover those contracts, and replaces
ui_storyboard_assemble_timeline with a version that actually drives the
timeline bridge — the handoff is the thing under test. ui_brief_* and
ui_review_* are eval instrumentation, not a frontend contract: a brief passed
only in the prompt can't be told apart from one the model ignored.
Rendered clips come back 1.35× the requested length, the way a video model that emits fixed-length takes does, so a cut planned to exactly fill the brief overruns. Catching that and trimming — the last clip, since shortening an earlier one only opens a gap and leaves the runtime untouched — is what separates a scoring run from a passing-looking one.
The predicates grade outcomes, not the shape of the process. Three checks were rewritten after live runs, all the same mistake: they encoded one valid working order and failed models that used another.
- Severity was a three-value enum that threw on
"critical", failing a run on this harness's vocabulary. Synonyms now map. - Overrun detection grepped the note prose for runtime/duration/length, and scored a run that found the overrun and fixed it as a miss on wording. It now reads the severity the model assigned.
reviewActedOncounted edits after the first review note, requiring report-before-fix. A sonnet run assembled at 16.20s, trimmed and ripple-moved to 12.00s, verified withui_review_get_cutand then filed notes as a sign-off — a complete loop scored as "review changed nothing". It is nowcutRevisedAfterAssembly, which accepts either order.
The SDK throws on its turn cap rather than stopping, so a low cap scores the
whole case zero — full-pipeline needs --max-iterations 220 to clear. The
suite costs real money.
IS_SANDBOX=1 npm run dev:nodetool -- eval creative-pipeline \
-p claude_agent_sdk -m sonnet --max-iterations 220 --no-find-modelscripts/dump-creative-run.ts runs one case and writes the work itself —
concepts, style-frame prompt, shot list, the assembled cut with timings, review
notes, phase snapshots and the full tool transcript — to
nodetool-debug/creative-<case>.{md,json}. The eval report gives pass/fail and
call counts, which is right for a scoreboard and useless for seeing what the
model made.
IS_SANDBOX=1 npx tsx packages/agents/scripts/dump-creative-run.ts \
full-pipeline claude_agent_sdk sonnet 220Live media (--live). The suite fakes every generate/render, which is what
makes it a CI-priced eval. Pass --live and the same tool calls additionally
hit fal, so the run leaves real stills and clips in
nodetool-debug/creative-<case>-media/ without changing a tool contract or a
predicate. One run's output is checked in at docs/evals/creative-pipeline/
so the suite's media can be inspected without paying for a run. MediaBackend is an interface in the bridge; the fal wiring lives in
the script, because packages/agents has no fal dependency and should not grow
one for an opt-in path.
Stills default to openai/gpt-image-2, clips to
ltx-2-19b/distilled/image-to-video; override with CREATIVE_IMAGE_MODEL /
CREATIVE_VIDEO_MODEL. The first draft picked flux/schnell on cost grounds
and it was the wrong trade — flux mangles hands and the brief requires them in
three of four shots. Media is the cheap part of a live run; the agent loop
driving it dominates the bill.
Three caveats. The timeline still lays clips at the simulated overshoot, so the
scored runtime is not the runtime of the files on disk — the real overshoot has
run wider than the 1.35× modelled, so the planted defect is conservative; check
a runtime assertion against the takes on disk. The provider reads
FAL_API_KEY, not FAL_KEY. And no predicate can see the pixels:
forbiddenAvoided reads shot text and layer names, so a run passed it while
gpt-image-2 branded a bottle with lettering the brief forbade. The suite grades
the plan; grading the artifact needs a human or a vision model.
FAL_API_KEY=$FAL_KEY IS_SANDBOX=1 npx tsx \
packages/agents/scripts/dump-creative-run.ts full-pipeline claude_agent_sdk sonnet 220 --liveA case that declares permission: { mode, approve? } runs its belt through
the real gateTools ladder with a scripted approver
(src/evals/tool-loop-permission.ts), and every approval request is recorded
on permissionRequests for the case's expect.permissionRequests predicates,
which count as final-state checks. plan-mode-blocks-mutation and
denied-mutation-stays-out on the graph world assert that plan mode blocks a
write without asking and that a denied write leaves the graph untouched; both
are keyless. A gated case names its node types outright: ui_search_nodes
has no permission class of its own, so it classifies external and plan mode
blocks it.
Every other tool-loop case is fully specified: the prompt carries everything the
model needs, so guessing is never required and never penalized. This suite
removes that guarantee. Each case withholds something only the user can supply
— the names for an input and output, permission to delete a node, a choice
between two node types that fit equally well, a capability the catalog does not
have — and hands the model an ask_user tool wired to a scripted user
(src/evals/escalation.ts). The question is matched against the case's reply
script, the matching reply comes back as the tool result, and every exchange is
recorded.
That makes the score a pair, not a single judgement: escalation.mustAsk names
the reply the model has to trigger, and the case's finalState predicates check
that it then built what the answer said. A model that guesses fails on the ask;
one that asks the right question and ignores the reply fails on state. An
off-script question gets a deliberately useless fallback answer and trips
allQuestionsMatched, and askBefore is the confirm-before-you-act constraint
— ui_delete_node must not precede the first ask_user.
The fifth case, no-escalation-needed, guards the opposite failure: the
objective pins every value, ask_user is on the table, and reaching for it is
itself the failure. Without it the suite would reward a model that asks about
everything.
Escalation is a property of the generic runner, not of the graph surface — any
tool-loop case on any surface can declare escalation and get the same tool and
the same checks.
npm run dev:nodetool -- eval workflow-escalation --list
npm run dev:nodetool -- eval workflow-escalation -p anthropic -m claude-sonnet-5
npm run dev:nodetool -- eval workflow-escalation -p openai -m gpt-5.4-mini --min-success 0.8confirm-before-delete is the case models fail: they read the graph, delete
the dead branch, and never ask. escalate-missing-capability costs the most
calls, most of them ui_search_nodes hunting for an image node the catalog
does not have before accepting it isn't there. Run it with
--max-iterations 40 --no-find-model.
Harness tests, including a golden transcript per case so no case can be
unsatisfiable: tests/escalation-tool-loop.test.ts.
memory-tools is the odd one out: instead of reimplementing a browser
surface, its bridge executes the real backend tools (memory_save,
memory_list, memory_search, asset_search) plus a stub generate_image
against an in-memory DB (initTestDb), so it exercises the actual persistence
- resource validation a chat turn does. It scores the creative loop: generate
media → remember it with an asset reference → recall it — and one case seeds a
memory in a different thread, which only a cross-thread
memory_searchfinds.
Bridges reuse the pure packages where the real logic already lives —
@nodetool-ai/timeline (splitClip, ANIMATION_PRESETS, subtitle assembly,
clip/track factories) — rather than reimplement. The sketch surface reimplements
its layer-stack ops directly, but not its pixels: every raster layer is an
@napi-rs/canvas bitmap. ui_sketch_stroke runs the editor's paint core
(@nodetool-ai/image-editor/painting.js) and fill / gradient / shape / transform
/ adjust / crop / selection-shape run @nodetool-ai/image-editor/raster.js, both
pointed at skia with setPaintSurfaceFactory(createCanvas), so a headless edit
is the edit the browser would paint. ui_sketch_get_layer_image composites those layers —
opacity and blend mode included, NodeTool's "normal"/"add" mapping onto
Canvas's "source-over"/"lighter" — and hands the model a PNG of its own
work. SketchToolBridge.compositePng() (or getLastSketchToolBridge(), for a
bridge the eval runner owns) takes the finished drawing out for a human to look
at. That is what makes draw-an-animal scoreable: it checks that strokes
landed on several named layers and covered a real fraction of the canvas
(strokedFraction, measured over stroked layers only so a solid fillColor
backdrop cannot pass it) — outcomes, not a pixel-exact cat.
Browser-only tools (asset capture, WebGL viewport render) are scoped out:
ui_sketch_render_to_asset,
ui_timeline_get_clip_frames, ui_3d_capture_view. Storyboard cannot import
@nodetool-ai/llm-nodes (it depends on @nodetool-ai/agents), so its
generate/render jobs are faked by flipping shot status. Its board holds wire
Shot objects and normalizes every write through
normalizeStoryboardScreenplay (@nodetool-ai/protocol/api-schemas), and
finalState().savable reports whether storyboards.update would accept what
the board now holds — a bridge that minted the id/index/status a model
omits kept this suite green through a bug that lost a user's board. The
app-builder surface
reimplements only the Puck layout ops (nested slot tree: top-level content plus
slot-valued props on Panel/Columns) headlessly — those live in web/
(puckDataOps.ts), which a backend package can't import. Its operation,
variable, resource, and binding-target tools call the shared doc-ops in
@nodetool-ai/app-runtime (src/doc-ops.ts), the same module the browser
handler calls, so that half of the contract cannot drift. The widget types it
offers come from WIDGET_CATALOG in the same package — every widget the editor
ships, with the fields each accepts — so ui_app_list_component_types reports
the same catalog headlessly that the browser reads off the live Puck config.
npm run dev:nodetool -- eval timeline-tools --list
npm run dev:nodetool -- eval script-tools -p anthropic -m claude-sonnet-5
npm run dev:nodetool -- eval sketch-tools -p ollama -m qwen-3.5:4b --cases compose-layers
npm run dev:nodetool -- eval model3d-tools -p openai -m gpt-5.4-mini --min-success 0.8 # CI gateHarness tests (scripted provider, no network): tests/tool-loop-eval.test.ts
plus one per surface (tests/{script,js-script,sketch,timeline,storyboard,model3d,app,memory,creative-pipeline}-tool-loop.test.ts).
For a live check against a real model, run the eval command above — a suite
whose verdict depends on what a model chose belongs behind an explicit
invocation, not in the unit suite, where a weak local model fails the run for
everyone.
Running against the claude_agent_sdk provider. Two gotchas, both from the
SDK's own agent loop (not the harness):
- Turn cap throws. The SDK raises
error_max_turnswhen it reaches its turn limit, so a run that would merely stop under a stateless provider (Anthropic, Ollama) instead errors and the case scoresaccepted=false. Its turn accounting also counts each tool round, so the default--max-iterations 12is easily exhausted by an over-searching model. Pass a higher cap (--max-iterations 40) when driving these suites withclaude_agent_sdk. uid=0refusal. The tool path runs the CLI underbypassPermissions, which it refuses as root; setIS_SANDBOX=1(or run non-root). It must be exactly1— the SDK's sandbox check is value-sensitive, so an ambientIS_SANDBOX=yes(as in Claude Code on the web) does not satisfy it and the child exits with code 1 and zero tool calls, which looks like an auth/spawn failure but isn't. Override it explicitly:IS_SANDBOX=1 npm run …. See docs/AGENTS.md § Claude Agent SDK for the full nested-session recipe.
IS_SANDBOX=1 npm run dev:nodetool -- eval timeline-tools \
-p claude_agent_sdk -m sonnet --max-iterations 40 --no-find-modelWhere the tool-loop suites score a model on one flat tool surface, the
subtask suite scores RunSubtaskTool — the primitive that lets an agent
decompose work by spawning a fresh child agent that inherits the parent's
toolset. It runs a real CodeActExecutor parent equipped with run_subtask plus
six instrumented worker tools (calculate, kv_write, kv_read,
lookup_fact, slugify, flaky_fail), each objective written to force
delegation. The tools are shared instances at both levels; each records the
SUBTASK_DEPTH_KEY it ran at, so the scorer distinguishes "the parent did it
itself" (depth 0) from "the parent delegated and the child did it" (depth >=
1). Scoring is structural (checkSubtaskExpectations): required parent tools,
required child tools, forbidden tools, subtask-count and depth bounds, no
failed subtasks, required store keys, and answer/subtask-result substrings.
Cases + tools live in src/evals/subtask-cases.ts, the runner in
src/evals/subtask-eval.ts.
npm run dev:nodetool -- eval subtask --list
npm run dev:nodetool -- eval subtask -p anthropic -m claude-sonnet-5
npm run dev:nodetool -- eval subtask -p openai -m gpt-5.4-mini --cases all-tools
IS_SANDBOX=1 npm run dev:nodetool -- eval subtask \
-p claude_agent_sdk -m sonnet --max-iterations 40 --no-find-modelIts cases do not use find_model, so --no-find-model does not skip them —
the primary -p provider runs both the parent and every subtask. A low score
with subtasks=0 is a real finding, not a harness bug: a capable model often
does trivial single-step work inline instead of delegating. Harness tests
(scripted provider, no network): tests/subtask-eval.test.ts.
The only suite that scores a whole product loop rather than one stage:
buildApp (src/app-build/) takes a prompt through spec → plan → author →
check → run → judge, repairing what the oracle complains about, and the suite
counts how often that ends green and how much repair it took. Cases in
src/evals/app-build-cases.ts, runner in src/evals/app-build-eval.ts.
Metrics per docs/mini-app-build-harness-design.md §5.3: one-shot rate
(green with zero repair rounds — the PRD's north-star number), green-within-
budget rate (the suite's successRate, what --min-success gates on), repair
rounds, cost, and duration.
A case is green only when the build's own verdict is ok and its target-shape
checklist holds — operations, workflows, widget count, a widget nested in a
container, a persist: true variable, a streaming output shown by a display
widget, an operation reading a variable another wrote, and a widget carrying a
condition. Without the checklist a build that shipped one operation and three
widgets would score as a success. Each prompt case declares which
of the six medium-complexity traits (PRD §4) it exercises;
uncoveredAppBuildTraits() names any trait that lost its last case, and the
harness test fails on a non-empty answer.
The two deterministic cases (greeting-card, draft-then-publish) pin the
spec, bind template graphs (text transforms — no model in the app under test),
author from a scripted list of ui_app_* calls, skip the judge, and assert
exact widget values. They call no provider, so they run on every PR as the
Quality Gate's app-build leg; what they regress is the harness, not a model.
The full suite runs nightly (.github/workflows/app-build-eval.yml), reports,
and gates nothing — a model's off night is not a broken build.
npm run dev:nodetool -- eval app-build --list
npm run dev:nodetool -- eval app-build -p anthropic -m claude-sonnet-5
npm run dev:nodetool -- eval app-build --cases greeting-card,draft-then-publish \
-p ollama -m none --no-find-model --min-success 1 # no API key neededHarness tests (scripted authoring, stub kernel runner, no network):
tests/app-build-eval.test.ts.
The CLI renders a real-time tree view during agent execution:
✓ initialization Starting parallel task planning...
✓ generation Generating parallel plan...
✗ validation Plan validation failed: duplicate step IDs
✓ generation Retry attempt 2/3...
✓ complete Plan created: 5 tasks, 5 steps, 5 parallelizable
◆ Plan (3/5 tasks)
├─ ✓ Task 1: Search sources 3.2s (1/1 steps)
├─ ◐ Task 2: Analyze findings
│ ├─ ✓ google_search(query: "AI trends")
│ └─ ◐ llm_call
└─ ○ Task 3: Write report waiting
All execution events are yielded as ProcessingMessage:
| Type | Description |
|---|---|
planning_update |
Planning phase progress (initialization, generation, validation, complete) |
task_update |
Task lifecycle (task_created, step_started, step_completed, step_failed, task_completed) |
tool_call_update |
Tool invocation with name and args |
step_result |
Step completion with result or error |
chunk |
Streaming text output |
log_update |
Informational log messages |
llm_call |
Full LLM call details (provider, model, messages, response, tokens, cost, duration) |
# Verbose logging to stderr
export NODETOOL_LOG_LEVEL=debug
# Log to file
export NODETOOL_LOG_FILE=/tmp/agents.logSpan hierarchy (an analyzer agent can read this tree to optimize prompts):
workflow.run
node.process
agent.execute
agent.plan (TaskPlanner.planMultiTask / authorGraph)
llm.chat (BaseProvider.generateMessageTraced)
llm.stream (BaseProvider.generateMessagesTraced)
agent.step (CodeActExecutor.execute)
llm.chat
llm.stream
Span attributes:
agent.*:agent.kind(execute/plan/step),agent.objective,agent.provider,agent.model,agent.tools_count,agent.task(for steps),agent.plan.kind(multi/single/graph)llm.*:llm.provider,llm.model,llm.request.message_count,llm.request.tools_count,llm.request.max_tokens,llm.request.stream,llm.response.content(first 2000 chars),llm.response.tool_calls_countgen_ai.*(OTel GenAI semconv):gen_ai.system,gen_ai.request.model,gen_ai.operation.name,gen_ai.usage.input_tokens,gen_ai.usage.output_tokens,gen_ai.usage.total_tokens,gen_ai.usage.cost_usdworkflow.*/node.*:workflow.id,workflow.name,workflow.node_count,node.id,node.type
Sinks (simultaneous, each on its own SpanProcessor):
# JSONL trace file — one span per line, analyzer-friendly
export NODETOOL_TRACE_FILE=/tmp/nodetool-trace.jsonl
# Stdout — pretty (human) or json (JSONL)
export NODETOOL_TRACE_STDOUT=pretty # or "json"
# OpenTelemetry — console (legacy)
export OTEL_TRACES_EXPORTER=console
export TRACELOOP_DISABLE_BATCH=true
# OpenTelemetry — Traceloop cloud
export TRACELOOP_API_KEY=your-key
# OpenTelemetry — custom OTLP backend (Jaeger, Grafana, etc.)
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318CLI flags pass these through:
nodetool-chat --trace-file trace.jsonl
nodetool-chat --trace-stdout pretty
nodetool --trace-file trace.jsonl run workflow.tsTelemetry must be initialized before use:
import { initTelemetry } from "@nodetool-ai/runtime";
await initTelemetry({
traceFile: "trace.jsonl", // optional
stdout: "pretty", // optional: "pretty" | "json" | false
});The CLI calls initTelemetry() at startup automatically. The WebSocket server requires env vars to be set before starting.
The web UI renders the same tree view in the chat panel (ExecutionTree component). The TracePanel provides a detailed event inspector with token counts, costs, and full request/response payloads.
CostCalculator in @nodetool-ai/runtime tracks per-call costs based on provider pricing:
provider.trackUsage(model, { inputTokens: 100, outputTokens: 50 });
console.log(provider.getTotalCost()); // USDCosts are logged via logProviderCall() and included in llm_call messages.
One run, one model. create_plan and execute_plan both use the calling
session's provider and model (subAgentRuntime), so planning and step
execution cannot drift apart or bill against a model the user did not pick.
The per-phase model idea survives only as a possible argument on those two
specs; nothing selects a second model.
A single result is cut, and a whole thread is summarized. Two different limits, in two different places.
- One tool result is capped at 25 000 characters (
MAX_TOOL_RESULT_CHARSinconstants.ts) before it enters history, with a notice telling the model the output was cut and how to ask for a smaller slice. A broad grep or a large file read otherwise returns tens of megabytes and the next request fails outright. - A step transcript is never summarized. Step executors hand the
tool-calling loop to
provider.generateLoop, so each provider manages its own window — the Claude Agent SDK compacts internally, a stateless provider is sent everything. What bounds a step instead is the cumulative iteration cap and the result cut above. - A chat thread is compacted, in
packages/websocket(session/chat-compaction.ts, driven fromchat-turn.ts). One summarizer call replaces everything before the lastNODETOOL_CHAT_COMPACTION_KEEP_TURNSuser turns, and the summary is persisted as arole: "user"row markedexecution_event_type: "compaction". History assembly starts from the newest such row, so only the provider's view is shortened — the full thread stays in the database for the UI andnodetool.threads.*. The cut lands on a user message, which is what keeps a tool call attached to its result. - Two triggers: the estimated prompt crossing
NODETOOL_CHAT_COMPACTION_TOKENSbefore the loop, and the provider itself reporting that the prompt did not fit, which compacts and retries the turn once. A provider holding the transcript upstream skips the first, since shortening what NodeTool sends does not shorten what that provider already has. - A failed summarizer leaves the thread uncompacted and the turn runs
against the full history, with a
log_updatesaying so. The alternative to an imperfect summary is a turn that cannot run at all. - The user sees the cut. The web renders the compaction row as a collapsed
"Earlier conversation summarized" card
(
web/src/components/chat/message/CompactionCard.tsx), reached fromMessageViewbefore the plain user-message path — without that branch the summary reads as something the user typed.
Plans are validated before execution:
- Step/task IDs must be unique across the entire plan
- Dependencies must reference valid IDs
- No circular dependencies (DAG validation via DFS)
- On failure, the error is fed back to the model as the tool result, and it
calls
add_taskagain
Steps can enforce structured output via JSON schema:
additionalProperties: falseenforced automatically- Schema'd steps finalize ONLY through the finishing tool —
finish_stepin tool mode,finish()in a CodeAct action. There is no JSON-from-text extraction path, so a step that never calls it fails and emits an explicit error result. - Unstructured steps (no schema) finalize when the model emits a no-tool-call assistant message; that text becomes the result.
- Two things make that contract visible to a model that believes
return graphfinished the step, because the observation for the losing move used to be indistinguishable from success. A CodeAct observation for a schema'd step that returned a value without finishing carriesfinished: falseand anote— which says so explicitly when the returned value already matched the schema. And a schema'd step whose turn ended in prose is re-prompted with the contract, at mostMAX_FINISH_NUDGES(2) times, before it fails. - The failure message names the terminal state it actually hit: the provider's error, the run budget's own reason when a cost cap or deadline stopped the step,
exceeded N iterationsonly when the loop really used its budget, else "ended after N action(s) / model turn(s) without calling finish", quoting the model's last message. A budget stop reported as "without calling finish" blamed the model for something it never got to do. maxIterationsbounds the step, not each finish-nudge round. Each round is given what is left of the allowance and a round with nothing left is not run, soMAX_FINISH_NUDGESno longer multiplies the ceiling — a step configured for 4 model turns used to be able to make 12.
Skills come in two tiers.
A user skill is a row in the skills table — name, description, and
markdown content — that someone wrote and can rewrite.
A system skill ships with the build: a SKILL.md under
packages/system-skills/<name>/, frontmatter naming it, and it is read-only
at runtime. The tier exists because the two properties a user row cannot have
both matter: every install has the skill on day one with no seeding migration to
drift per machine, and nothing — including an agent acting on its own
mis-read instructions — can edit or delete the document it is working from.
system-skills.ts reads them (cached per process) from _skills/ beside the
bundled server.mjs, else packages/system-skills on the way up from the
module, else NODETOOL_SYSTEM_SKILLS_DIR — the same two-root shape as the
sandbox packs, and for the same reason: nothing imports them, so they are not
workspaces and npm links nothing. bundle-backend.mjs stages them and
verify-backend-bundle.mjs fails a build that ships none.
The *-prompting skills carry a generation model line's prompting rules —
image (nano-banana-pro-prompting, gpt-image-2-prompting,
flux-2-klein-prompting, seedream-prompting, qwen-image-prompting), video
(seedance-2-prompting, veo-3-prompting, minimax-h3-prompting,
wan-2-6-prompting, kling-video-prompting, hailuo-prompting) and audio
(elevenlabs-audio-prompting, stable-audio-prompting).
MODEL_PROMPTING_SKILLS in
model-prompting-skills.ts maps a model id onto one, and find_model attaches
the answer as prompting_skill on every matching route — the catalog line is
the other path in, for an agent that never called find_model.
tests/model-prompting-skills.test.ts checks the table against the skills on
disk, the ids the shipped provider manifests name, and the capability registry.
Both tiers share one catalog. list_skills and load_skill serve either, each
answer carrying system: true|false; create_skill, update_skill and
delete_skill refuse a shipped name, including a rename onto one. A user row
that already held the name predates the reservation, so it wins and the
shipped skill is not listed twice — reserving names stops new collisions, not
old ones. A malformed SKILL.md is skipped rather than fatal: one bad shipped
file must not cost a user every other skill.
Each record has name, description, and markdown content columns. Agent discovery reads
the current user's records through the models layer and merges trusted
sandbox-pack skills supplied for the session.
Nothing auto-selects a skill. Word overlap between an objective and a
description picked the wrong document often enough that the catalog plus
load_skill replaced it: the model reads what exists and asks for the one it
wants. Filesystem SKILL.md discovery and the NODETOOL_AGENT_SKILL_DIRS,
NODETOOL_AGENT_SKILLS and NODETOOL_AGENT_AUTO_SKILLS environment variables
are gone with it.
A chat turn discovers skills the way it discovers sandbox packs — a catalog in
context, a body behind a call. The websocket runner's buildSystemContent folds
formatSkillCatalogForPrompt (src/skill-prompt.ts) into every turn's system
prompt: one line per skill, name and description, plus the instruction to call
load_skill before acting on one. The block is ephemeral — it reaches the provider and is
never persisted, so it reflects the table as it is right now.
/<name> skips that round trip. A message naming a skill with a leading slash
is asking for it, so findInvokedSkillNames matches the text against the user's
own names and formatInvokedSkillsForPrompt puts those bodies in the same
block. The match needs a word boundary, so src/utils is a path and not an
invocation. The composer's / autocomplete
(web/src/components/chat/composer/useTextareaSkillMention.tsx) only types the
text; the expansion is server-side, so a headless client gets it too.
The skills capability module (src/capabilities/skills.ts) is the rest:
| Capability | Does |
|---|---|
list_skills |
Names and descriptions, optionally filtered by query |
load_skill |
One skill's full instructions by name |
create_skill |
Author a new skill (names are unique per user) |
update_skill |
Change name, description or body |
delete_skill |
Remove one |
Every one is scoped to context.userId and refuses a run that carries none.
Tests: tests/capabilities-skills.test.ts.
- Reduce cost: run the session on a cheaper model, and give the run a
RunBudgetwith a USD cap so a loop stops instead of billing - Improve plan quality: pass a
systemPromptpreamble toTaskPlanner— it is prepended to the TaskArchitect contract, not swapped for it - Speed up execution: decompose into more independent tasks, since dependency edges are what serialize the DAG
- Control scope:
maxStepIterationsbounds one step's action rounds; the run'sRunBudgetbounds everything else — nothing counts dispatch rounds - Validate output: use
outputSchemato enforce structured step results - Restrict tools: per-step
toolsarrays limit which tools a step can call - Observe: enable tracing (
OTEL_TRACES_EXPORTER=console) to see every LLM call - Iterate on skills: add domain-specific records in the Skills panel, and
name one with
/<name>to load its body without a round trip
When building a node that wraps an agent (e.g. llm-nodes AgentNode):
- Every tool named in an agent's system prompt must actually be registered in
its toolset. A prompt-referenced-but-unregistered tool is a silent
no-op. Resolve real builtin tools (
resolveBuiltinAgentTool) and don't reference tools you didn't wire. - Every declared prop must be consumed by
process()or injected into the prompt. A declared-but-unwired prop (max_output_chars,url,output_dir) does nothing. yieldstructured results so the kernel routes them to dynamic output handles; don'treturnthem from a generator (yield*discards the return value). Keep structured-output emission consistent across modes (loop vs plan).