JS Script Documents: a first-class, executable script type

Status: implemented (phases 1–3). Owner: code-nodes / agents / web. 2026-08-12.

Where the implementation differs from the design below, the difference is noted inline under Implementation note.

Problem

Reusable JavaScript exists in NodeTool in exactly two forms, and neither is a document a user can open, edit, test, and share:

  1. A Code node body — a code string inside one workflow’s graph. It cannot be reused by a second workflow without copy-paste, it has no version history of its own, and editing it means opening the workflow that happens to contain it.
  2. A sandbox pack — an npm-shaped artifact installed through the Package Manager. It is a distribution format, not an editing surface: changing one means republishing a package.

The gap shows up in three places. A mini app that needs ten lines of glue (reshape an API response, format a date, merge two lists) must author a whole workflow around one Code node. An agent that has debugged a useful body with run_code has nowhere durable to put it. And two Code nodes that need the same helper each carry their own diverging copy.

This design adds a JS script document: a named, versioned, user-owned script with declared inputs, outputs, packages, secrets, and saved test cases. It opens in its own workspace tab (Monaco editor plus an assistant chat panel, the Code node assistant’s layout as a full surface), runs and tests against the QuickJS sandbox, and is invocable from mini apps (as an operation), from Code nodes (as a linked body), from agents (belt tools), and from other scripts (through the same belt).

Naming: the scripts table, the script tab type, and the nodetool.scripts guest namespace are all taken by the video/voiceover script feature. This feature uses js_scripts / jsscript / ui_jsscript_* / *_js_script throughout.

Document shape

One JSON document per script, stored whole and validated with Zod at the wire boundary (schema in packages/protocol/src/api-schemas/js-scripts.ts, types exported from @nodetool-ai/protocol):

interface JsScriptDocument {
  schemaVersion: 1;
  description: string;              // what it does — agents read this to pick a script
  code: string;                     // the body, emit/output contract only
  inputs: JsScriptPort[];           // { name, type }  — same TypeMetadata names nodes use
  outputs: JsScriptPort[];
  packages: SandboxModuleDeclaration[];   // sandbox packs the body may import
  secrets: string[];                // names readable via getSecret; [] = none
  timeoutSeconds: number;           // default 30, max 120 (MAX_TIMEOUT_SECONDS)
  tests: JsScriptTestCase[];        // saved cases, same shape test_code grades
}

interface JsScriptTestCase {
  name: string;
  inputs: Record<string, unknown>;
  inputStreams?: Record<string, unknown[]>; // staged items, for a `stream` body
  expect?: Record<string, unknown>;         // per-handle structural compare
  expectedStreamed?: { name: string; value: unknown }[];
}

Decisions baked into the shape:

  • Emit/output contract only. Scripts are a new surface that launches after docs/code-node-emit-design.md; there is no legacy return-contract path to support. validate_js_script rejects a body that calls neither emit nor output while declaring outputs — no deprecation warning, an error.
  • Tests live in the document. test_code already grades a case list; a script carries its own regression suite, so the editor’s Test button, the assistant’s repair loop, and the CLI harness all run the same cases. A saved script with zero tests validates with a warning.
  • Ports are declared, not inferred. validateCodeNodeBody already takes availableInputs/declaredOutputs as lists; for a Code node the graph derives them, for a script the document declares them. Everything downstream (app input mapping, Code node linking, run_js_script argument checking) reads the declared ports.
  • The script pins its own execution envelope (packages, secrets, timeout). A caller can narrow it — never widen it. Invoking a script does not grant it the caller’s secrets; it runs with the intersection of its declared secrets and what the invoking context allows.

Storage and API

Copy the sketch pattern (image_documents + image_document_versions), the full-featured reference for document types:

  • packages/models/src/schema/js-scripts.ts — table js_scripts: id, user_id, project_id, name, document (TEXT JSON), created_at, updated_at, indexes on user/project/updated. Postgres mirror in schema-pg/.
  • packages/models/src/schema/js-script-versions.ts — table js_script_versions: id, js_script_id (cascade), user_id, name, version, save_type ("manual" | "autosave" | "restore"), document, created_at, unique on (js_script_id, version).
  • packages/models/src/js-script.tsclass JsScript extends DBModel, beforeSave bumps updated_at monotonically and asserts the document validates; CAS save via updateFieldsIfUnchanged.
  • Migration entries in packages/models/src/migrations/versions.ts (create_js_scripts, create_js_script_versions).
  • tRPC router packages/websocket/src/trpc/routers/js-scripts.ts: list / get / create / update / delete, update carrying the read updatedAt as the CAS token, plus a nested documentVersions sub-router (list / get / create / restore / delete) copying sketch.tsrestore snapshots the current document as a restore version before overwriting, so a restore is itself undoable. Mounted as jsScripts in trpc/router.ts.
  • One run endpoint for non-tRPC callers (mini apps, harnesses): POST /api/js-scripts/:id/run {inputs, input_streams?} executes the script and streams the same message shapes a workflow run streams (node_progress for progress(), per-emit output messages, a final output bag), so the app-runtime fold consumes it unchanged.

Editor tab

New WorkspaceTabType member "jsscript" in web/src/stores/WorkspaceTabsStore.ts; the exhaustive switch in TabContent.tsx forces the new case, JsScriptSurface.tsx.

The surface is the Code node assistant dialog promoted to a document editor:

  • Left: Monaco (useMonacoEditor), editing the persistent document — not a draft. Autosave through useJsScriptServerSync (copy useScriptServerSync: load on mount, 750 ms debounce, CAS on updatedAt, retry after 5 s, registerDocumentSync for cross-client resource_change).
  • Right: assistant chat panel, the same panel component the Code node assistant uses, wired to the ui_jsscript_* tools below. Where the node assistant edits a draft and Apply commits to the node, the script assistant edits the document directly — undo is the document’s own history plus the version snapshots.
  • Header/sidebar: ports (inputs/outputs with types), packages picker, secrets list, timeout — the same controls the Code node property panel renders for its dynamic slots and packages prop.
  • Bottom: run console. A Run button (prompting for input values from the declared ports), a Test button running the saved cases, and a console showing logs, streamed emits, final outputs, and errors — the run_code/test_code result shapes rendered.

Threading a tab type through the shell touches the known list: PanelStore.LeftPanelView ("jsscripts") + JsScriptListPanel / CreateJsScriptButton in PanelLeft.tsx, OpenMenu.tsx, openDocument.ts (OPENABLE_TYPES), uiContext.ts (TAB_TYPE_TO_SURFACE), documentSync.ts (SyncedDocumentType), resourceChangeHandler.ts, IconForType.tsx, and UiSurfaceType in packages/protocol/src/api-types.ts.

ui_jsscript_* tools

Frontend tools in web/src/lib/tools/builtin/jsscript.ts (registered via builtin/index.ts), executing against a per-document bridge (jsScriptAgentBridge.ts + useJsScriptAgentBridge, mounted from the surface — same seam as scriptAgentBridge.ts). Every tool takes the script id; ui_open_document bridges “an id exists” → “the tools work”.

Tool Effect
ui_jsscript_get_state document + validation issues + last run/test result
ui_jsscript_set_code replace the body
ui_jsscript_set_ports replace inputs/outputs
ui_jsscript_set_packages replace pack declarations
ui_jsscript_set_meta name, description, secrets, timeout
ui_jsscript_set_tests replace the saved case list
ui_jsscript_run run with given inputs, return outputs/streamed/logs/error
ui_jsscript_test run the saved cases, return the grade report

Run and test execute server-side (the browser calls the run endpoint / capability, not an in-browser QuickJS), so the editor, the assistant, and the CLI harness exercise one execution path.

Execution semantics

Execution reuses runCodeBody (packages/agents/src/capabilities/code.ts) — the hermetic core run_code/test_code already share — parameterized by the script document instead of ad-hoc arguments:

  • Globals: { inputs, state: {} }. Fresh state per run; inputs JSON deep-copied.
  • Limits: secretScope = the script’s declared secrets intersected with the caller’s allowance; timeoutMs from the document, capped by the caller.
  • Packages resolve through the process SandboxModuleCatalog; an undeclared import fails validation before it fails at run time.
  • No toolbelt in v1. A script is a function over its inputs — the same hermeticity run_code enforces. tools.* / nodetool.* are absent from the guest. This keeps “agent invokes script” free of privilege questions: the script cannot do anything the sandbox defaults don’t already allow (capped fetch, scoped secrets). Widening scripts to a belt is a separate decision with its own design if it’s ever wanted.

Invocation

By agents (and by other scripts)

One capability module js-scripts (packages/agents/src/capabilities/, specs + implementation, registered in both halves of capabilities/registry.ts):

Tool Effect
list_js_scripts id, name, description, ports — description is the discovery surface
get_js_script full document
save_js_script create or update (CAS)
validate_js_script static check of a document (inline or by id)
run_js_script execute by id/name with inputs; returns outputs/streamed/logs/error
test_js_script run the saved cases; returns the grade report

Because these are belt tools, every existing surface reaches them with zero new plumbing: agent JSON tool calls, CodeAct actions, MCP, and — the “invoked by other scripts” requirement — a Code node body or CodeAct action calling tools.run_js_script({name, inputs}) through the existing tool bridge. Guest→guest goes through the host, so budgets, depth, and secret scoping stay host-enforced.

Recursion is bounded the way sub-agents are bounded: run_js_script carries a depth counter on the ProcessingContext (the SUBTASK_DEPTH_KEY pattern) plus the chain of script ids; a script id already on the chain fails the call with the cycle named. Default depth cap: 4.

A direct guest import of another script (a nodetool-script:<id> specifier served by the catalog, next to how mountCapabilityModules mounts platform modules) is deferred: it would inline the callee into the caller’s capability envelope, so scoping and staleness need their own design. The tool path covers composition until then.

From Code nodes

A Code node can link a script instead of writing a body by hand. Linking materializes the pinned version: its code, packages, secrets and timeout are copied onto the node in one undoable edit, and the optional script prop ({ id, version }) records where they came from. The editor offers:

  • Link script — pick a script; its body, ports, packages, secrets and timeout are copied onto the node, whose code renders read-only while linked, with an “update to latest” affordance that re-copies and re-pins.
  • Extract to script — lift the node’s current body, ports, packages, and secrets into a new script document and link it. The inverse, detach, clears the script prop; the body is already inline.

At run time CodeNode executes its own properties — there is no resolution step, no database read, and no resolver on the ProcessingContext. A graph therefore runs wherever it is opened, and hydration answers the one question it has to answer statically: resolveStreamingInput reads node.properties.code to decide whether the node streams its inputs, which it can only do because the body is right there. Resolve-at-run would have made a linked streaming script hydrate buffered and fail on its first stream call.

Implementation note. The link is checked, not resolved. validateGraph is synchronous, so it takes an injected jsScriptLookup: a caller prefetches every link with collectJsScriptLinks and passes a lookup over what it found. The materialized body is validated exactly like an inline one — streaming rules included — and the lookup only answers the freshness question, comparing the script’s declared ports against the node’s slots (jsScriptPortMismatches in @nodetool-ai/node-sdk, js-script-link.ts). A link nothing can answer for is a warning (js_script_unverified without a lookup, js_script_missing with one): execution no longer needs the row, so a script that moved or was deleted costs freshness, not the run. The models-layer resolver (createJsScriptResolver) survives for the surface that still resolves a pinned version — a mini-app script operation — and for the editor’s link and update-to-latest, which read it over tRPC.

From mini apps

OperationBinding (packages/app-runtime/src/document.ts) today binds only a workflowId. It gains a discriminated target:

type OperationTarget =
  | { kind: "workflow"; workflowId: string; workflowVersion?: number }
  | { kind: "script"; scriptId: string; scriptVersion: number };

(workflowId on the binding stays readable for one schema version; APP_SCHEMA_VERSION bumps to 4 with a lift in documents.ts.)

Implementation note. The lift lives in document.ts, next to parseApplicationDocumentdocuments.ts is the sketch/timeline ref reader and has nothing to do with app documents. workflowId is not duplicated: a workflow target stores its id there as it always did, and only a script target stores a target. operationTarget(binding) is the normalizer that derives the union, and it is the only place the two kinds are told apart.

A script operation maps widgets to the script’s declared inputs and its outputs to displays/variables exactly as a workflow operation maps input and output nodes — the mapping layer already works on names. Execution goes through the run endpoint above; because it streams the same message shapes, the app-runtime fold (fold.ts), the app debug harness simulator, and the web runtime consume script operations without a second code path. nodetool app debug validates a script target the way it validates a workflow target: a binding naming a port the script does not declare is an error.

Implementation note. The run endpoint still answers with plain JSON, so the stream the fold consumes is synthesized from that result by one adapter, jsScriptRunMessages (@nodetool-ai/execution/app-debug, script-operation.ts): one output_update per emit in call order, one per final output, then the terminal job_update. A script’s ports stand in for node ids, since a script has no nodes. Executing a body lives above @nodetool-ai/execution, so the simulator takes an injected runScript; the hosts that own both wire createJsScriptAppRunner (@nodetool-ai/agents) — the server’s /api/applications/debug, the debug_app capability, and nodetool app debug. A host with no runner reports the operation as unexecutable rather than skipping it. The web app runtime is not wired yet: it still runs workflow operations only, and a script operation in the browser is the remaining gap — tracked here. The app builder has no script-operation authoring UI either; a script operation is authored through the document (bundle import, an agent’s document edit).

ApplicationBundle.scripts carries each pinned document under a bundle-local key. Because a script row has its own version numbering, import snapshots every carried script and re-pins the operations to the version it created (pinScriptVersions); the number the export carried means nothing in the importing database. @nodetool-ai/app-runtime stays dependency-free, so the carried document is typed structurally (BundleJsScriptDocument) and parsed against the pinned Zod contract wherever it is actually run.

ApplicationBundle gains scripts: BundledJsScript[] beside workflows, with the same bundle-local key indirection, so an app that uses scripts stays a single shareable file.

Validation

validate_js_script wraps validateCodeNodeBody (packages/node-sdk/src/code-node-validation.ts) with the document’s declared ports and packages — the analysis (syntax, undeclared imports, undefined names, undeclared inputs.* reads, unreachable output handles) is shared, not forked. On top of the body check, document-level rules:

  • duplicate or non-identifier port names; a port type the type system lacks
  • a test case referencing an undeclared input, staging a stream for one, or expecting an undeclared output
  • declared outputs with no reachable emit/output call (from the body check), and a body using the legacy return contract (error, not warning)
  • zero saved tests (warning)
  • a declared secret name that does not exist in the secret store (warning — the store is per-install)
  • the streaming rules under Input streaming

The check runs at save time (beforeSave), in the editor (issues panel), in the save_js_script capability, and in the CLI harness.

CLI harness and registry

Following the sketch/timeline pair, packages/cli/src/commands/js-script.ts (registered in nodetool.ts; heavy deps imported lazily inside actions):

nodetool jsscript validate <id|file.json> [--json] [--warnings-as-errors]
nodetool jsscript run <id|file.json> --inputs '{"a":1}' [--input-streams '{"nums":[1,2]}'] [--json]
nodetool jsscript test <id|file.json> [--json]          # saved cases
nodetool jsscript debug <id|file.json> --interact '[{"tool":"set_code",...}]'
nodetool jsscript versions list|show|create|restore|delete <id> [...]

debug replays a scripted session against the headless ui_jsscript_* bridge and validates the document the session left behind; test is the selfcheck-friendly command (a fixture script with deterministic cases runs keylessly). Report types live in @nodetool-ai/execution/js-script-debug; the CLI keeps target resolution and the bundle, as sketch/timeline do.

Registry (packages/cli/src/harness/registry.ts): harness entries jsscript-validate, jsscript-run, jsscript-test (selfcheck: cheap, no-db on file targets), jsscript-debug, jsscript-versions; a jsscript surface entry listing the paths above — the registry test fails without it.

Evals: a headless surface packages/agents/src/evals/surfaces/js-script.ts mirroring the tool names/shapes, wired as nodetool eval jsscript-tools, with cases covering authoring from a prompt, adding tests, and a repair loop (a failing case the model must fix).

Input streaming

A body reads its inputs one of two ways, and the body says which. The rules are the Code node’s — code-node-input-streaming-design.md — reused whole, because a body that runs in a script must run the same way in a node.

A script whose body calls stream(name) / stream.any() / stream.first() / stream.open() is a streaming-input script, detected with usesStreamInputContract. There is no document flag: adding the call is what makes it one, and deleting it is what makes it buffered again.

let total = 0;
for await (const n of stream("numbers")) {
  total += n;
  await emit("running", total);
}
await output("total", total);

Callers stage the items. A script has no inbox of its own — nothing upstream produces into it — so whoever runs it supplies what the body will pull:

Surface How
run_js_script input_streams: {handle: [item, …]}
POST /api/js-scripts/:id/run input_streams in the body
nodetool jsscript run --input-streams '{"numbers":[1,2,3]}'
The editor’s Run dialog one JSON array per declared input
A saved test case inputStreams on the case

Wire names are snake_case (input_streams) and the stored document field is camelCase (inputStreams), matching expectedStreamed beside it. A staged handle the script does not declare is refused by every surface rather than silently yielding nothing. Staged items reach the guest through the same pre-staged inbox run_code uses, so arrival order for stream.any() is round-robin by index across the handles in declaration order.

Validation runs validateCodeNodeBody with every declared input passed as both available and connected: a script has no node-configured property values, so each declared input is fed by the caller, which is what the graph calls a connected handle. Saying so gives the script exactly the rules it should have — stream("x") on a declared input never warns that nothing feeds it, and reading a declared input through inputs.x in a streaming body is the error naming stream("x"). A streaming body on the return contract stays an error. Two rules are the document’s own, both warnings: a case staging inputStreams for a body that never calls stream, and a streaming body whose cases stage nothing — each case then proves only that the body survives an empty inbox.

A mini-app operation stays buffered. A widget holds one value per input, not a stream of them. When the operation’s script streams, each mapped value is staged as a one-item stream and inputs is left empty (scriptOperationInvocation) — the split a graph run makes, where a connected handle is reachable through stream and never through inputs.

Security

  • A script executes as its owner for storage/versions, but its runtime capability envelope is only what the document declares: secretScope = declared secrets ∩ caller allowance, sandbox default fetch caps, no toolbelt, no filesystem beyond the workspace default.
  • run_js_script across users is not a thing: scripts are scoped to the requesting user, same as sketches and timelines.
  • Version restore re-validates against today’s schema (the timeline/sketch rule): an old document is restored, then checked, and a failing restore exits non-zero / returns the issues.
  • The script body is untrusted input to every consumer: the editor renders results, never evals; the only executor is the QuickJS sandbox.

Phases

  1. Document + editor: model, migration, router, tab surface, server sync, Monaco + assistant panel, ui_jsscript_* tools, run/test console. A user can author, run, and test a script end to end.
  2. Agent + harness surface: js-scripts capability module, nodetool jsscript commands, harness/surface registry entries, jsscript-tools eval. Agents and other scripts can invoke scripts.
  3. Attachment: Code node script link (link/extract/detach, pinned version, graph validation), mini-app OperationTarget + bundle scripts + app debug coverage. Landed, minus the web app runtime’s own execution path for a script operation (see the note under From mini apps).

Each phase lands green on its own; nothing in phase 1 depends on a later phase’s schema.

Testing

  • Model/router (packages/models, packages/websocket): CAS conflict on concurrent update; version restore snapshots first; document validation rejects bad ports/tests. New checks proven failable with inverted fixtures.
  • Validation (packages/node-sdk + capability tests): each document-level rule red on a fixture built to violate it, green on the shipped fixtures.
  • Execution (packages/agents): run_js_script hermeticity (no tools global), secret intersection, depth cap and cycle error, emit ordering parity with run_code.
  • Web (web/): surface renders, autosave CAS retry, bridge registration/teardown, tools against a mounted surface (Jest, RTL).
  • Harness: jsscript test on a fixture with one deliberately failing case exits non-zero; harness audit passes with the new surface entry.
  • E2E (phase 3): a mini app with a script operation runs under nodetool app debug; a workflow with a linked-script Code node runs under nodetool debug.

Out of scope

  • Guest→guest import of scripts (module specifier) — deferred, see Invocation.
  • A toolbelt inside script bodies — scripts stay hermetic functions.
  • TypeScript bodies, npm dependencies beyond declared sandbox packs, and script sharing/marketplace.
  • Mobile editing — mobile opens documents read-only per its architecture; script edits arrive through the chat agent’s tools like other documents.