Custom Nodes: user-saved node types in the library
Status: design. Owner: web / code-nodes / agents. 2026-08-18.
Problem
A user who has shaped a Code node into something useful — a scraper for one
API’s response shape, a formatter for their invoice numbers, a validator for
their CSV layout — has no way to keep it as a node. The shipped snippets
(web/src/config/codeSnippets.ts) show the right interaction: open the node
menu, find “Extract URLs”, drop it, and a pre-filled Code node appears. But
snippets are hardcoded into the build. A user’s own work has exactly one
durable home today, the JS script document
(js-script-document-design.md) — and a script
is reachable only through the Code node’s Link script picker, three clicks
deep inside a node that already exists. Nothing a user saves ever shows up
where they look for nodes: the node menu.
This design closes that gap. A custom node is a JS script document the
user has chosen to expose in the node menu. It appears under a My Nodes
section of the palette with its declared inputs and outputs, drops onto the
canvas like any node, and the graph it lands in stays portable: the placed
node is a plain nodetool.code.Code node with the script materialized onto
it and a provenance link back to the library.
No new document type, no new storage, no new execution path. The feature is a palette flag on the script document plus the wiring that makes flagged scripts show up and drop correctly.
What already exists
The three mechanisms this design composes, none of them modified in kind:
- JS script documents are the library.
js_scriptsrows own a versioned document withcode, declaredinputs/outputsports,secrets,timeoutSeconds, and saved tests (packages/models/src/js-script.ts, tRPC routerpackages/websocket/src/trpc/routers/js-scripts.ts). - The snippet palette path already puts virtual node types in the menu.
generateSnippetMetadata()(web/src/config/snippetMetadata.ts) fabricatesNodeMetadatarecords thatuseMetadata.tsmerges into the store, andinstantiatePaletteNode(web/src/utils/instantiatePaletteNode.ts) expands a virtual type into a realnodetool.code.Codenode on drop. The registry never hears about these types, and the saved graph never contains them. - The Code node script link already defines what a placed custom node is.
useCodeNodeScriptLink.materialize()(web/src/hooks/nodes/useCodeNodeScriptLink.ts) copies a script’s code, packages, secrets and timeout onto the node, derivesdynamic_inputs/dynamic_outputsfrom the declared ports, and recordsproperties.script = { id, version }as provenance. Graph validation checks link freshness throughjsScriptPortMismatchesandcollectJsScriptLinks(packages/node-sdk/src/js-script-link.ts,graph-validation.ts), and the editor already offers update to latest and detach.
A custom node is these three composed: a script (1) surfaced like a snippet (2) that drops as a linked Code node (3).
Document shape
One optional field on JsScriptDocument
(packages/protocol/src/api-schemas/js-scripts.ts):
interface JsScriptDocument {
// ...existing fields unchanged...
palette?: {
category: string; // menu grouping, e.g. "Text", "My API" — free text
};
}
- Absence means not exposed. Every existing script stays a plain library
script until its owner opts it in;
schemaVersionstays 1 and old saves parse unchanged. categoryis the only knob. The node’s title is the script’sname, its description is the document’sdescription, its ports are the declaredinputs/outputs— all of which the script already maintains for its other consumers. Duplicating any of them intopalettewould create a second copy to drift.- Validation (
validate_js_scriptand the document-level rules inpackages/node-sdk): apaletteon a script with zero declared outputs is a warning (the node would have no handles to connect), and an empty or whitespacecategoryis an error.
Palette surfacing
Client-side synthetic metadata, the snippet path — not registry registration.
The registry (NodeRegistry.global,
packages/node-sdk/src/registry.ts) is process-wide with no per-user
scoping, and it does not need any: the virtual node type exists only between
the menu and the drop, and the saved graph never references it. Registering
per-user types server-side is deferred (see Out of scope) until something
server-side actually consumes them.
New module web/src/config/customNodeMetadata.ts:
export const CUSTOM_NODE_PREFIX = "user.";
export function customNodeType(scriptId: string): string {
return `${CUSTOM_NODE_PREFIX}${categorySlug}.${scriptId}`;
}
export function generateCustomNodeMetadata(
scripts: JsScriptSummary[]
): Record<string, NodeMetadata> { /* ... */ }
- Node type is keyed on the script id, not the name: ids are stable and
unique, names collide and get renamed. The type string is transient (menu →
drop), so its looks cost nothing. Title and search text come from
nameanddescription, which is what the menu’s search index actually ranks (NodeSearchIndexweights title 6, description 1). - Namespace is
user.<categorySlug>, slugified the waysnippetMetadata.tsslugifies snippet categories.useNamespaceTreebuilds the tree from namespace strings alone, so My Nodes appears as ausertop-level group with the user’s categories under it, no tree changes needed.NamespaceIcon.tsxgets one entry for theuserroot. - Ports map directly. A declared input becomes a
Propertywith itsTypeMetadataand the type’s default value; a declared output becomes an output entry. No AST inference — scripts declare their ports, which is the precision the snippet generator never had.supports_dynamic_inputs/supports_dynamic_outputsstay true, since the placed node is a Code node. - A script whose body streams its inputs (
usesStreamInputContract) getsis_streaming_input: true, so the menu and the drop both say what the node will do.
Loading and refresh
Custom-node metadata is user data, so it cannot ride the unauthenticated
GET /api/nodes/metadata fetch. A hook beside useMetadata.ts —
useCustomNodeMetadata — queries jsScripts.list over tRPC (TanStack Query,
key ['jsScripts', 'palette']), generates the records, and merges them into
MetadataStore after the base metadata load. Refresh rides the existing
cross-client sync: js_scripts changes already broadcast resource_change
(registerDocumentSync), which invalidates the query; saving a script,
toggling its palette flag, or deleting it updates the menu without a reload.
Signed-out and error states merge nothing — the menu degrades to the shipped
catalog.
Drop semantics: materialize, with provenance
instantiatePaletteNode gains a third branch, before the snippet branch:
const script = findCustomNodeScript(metadata.node_type); // from the query cache
if (script) { /* materialize */ }
The branch does what useCodeNodeScriptLink.materialize() does today — the
port→slot mapping (portsToSlots / portsToOutputs) and property copying
move into a shared helper both call sites use, so drop and link cannot
drift:
properties:code,packages,secrets,timeoutfrom the document, andscript: { id, version }pinning the version that was dropped.data.title: the script’s name.data.codeNodeMode = "custom"— a third mode beside"snippet", socodeNodeUi.tsrenders the custom title and keeps it editable, and the property panel can show the link affordances (update to latest, detach, open script) without the user hunting for them.afterAdd:dynamic_inputs/dynamic_properties/dynamic_outputsfrom the declared ports, merged once the node exists in the store — the snippet pattern.
Everything after the drop is the already-shipped link behavior:
- The graph is self-contained. Execution reads only the node’s own
properties (
CodeNode.envelope()never resolves the link), so the workflow runs on the server, in the packaged app, and after export exactly as a hand-written Code node does. A deleted or moved script costs freshness warnings, never the run. - Staleness is visible, not silent.
validateGraphwith ajsScriptLookupreportsjs_script_missingand port mismatches throughjsScriptPortMismatches; the editor’s update to latest re-materializes and re-pins. Editing the library script does not mutate placed nodes — the same pin-and-update contract the link feature already made, now stated as the custom-node contract too: your graphs keep the version you dropped until you update them. - Detach turns the node back into an anonymous Code node; the code is already inline.
Save path: where “save my customized stuff” happens
Two entry points, both writing through the existing script machinery:
- From a Code node — “Save to My Nodes”. The Code node’s context menu
and property panel gain the action next to the existing Extract to
script; it is
extractToScript(which already lifts body, ports, packages, secrets into a new script and links the node) plus a small dialog for name and category that setspaletteon the created document. One action, and the node the user customized is now in the menu. A node already linked to a script offers Add to My Nodes instead, which just setspaletteon the linked script. - From the script editor. The
jsscriptsurface’s header gains a Show in node menu toggle plus the category field — the samepalettewrite over the existing autosave/CAS sync.ui_jsscript_set_metagains the field, so the script assistant can do it too.
Snippets stay as they are: a shipped snippet the user modifies is an
anonymous Code node until they save it, at which point path 1 makes it
theirs. No migration of codeNodeMode: "snippet" nodes.
Agents and headless surfaces
Nothing headless ever sees a user.* node type — graphs carry
nodetool.code.Code — so validate, debug, the kernel, mini apps, and
mobile need no changes. What agents need is authoring parity with the user:
- Discovery is
list_js_scripts, which already returns id, name, description, and ports. Its result gains thepalettefield so an agent can distinguish “the user’s nodes” from utility scripts, and the graph tools’ system prompt tells the agent to check it before writing a Code node body from scratch. - Placement: the
ui_add_nodepath in the web graph tools accepts the virtualuser.*type like any palette type (it goes throughinstantiatePaletteNodealready). Headless graph authoring (create_workflow, the DSL) materializes the same way a drop does; a shared helper in@nodetool-ai/node-sdk(materializeJsScriptNode(document, link)→ properties + dynamic slots) keeps the web drop, the link hook, and headless authoring on one code path.
Validation and safety
- The materialized body is validated exactly like any Code node body — the link adds freshness checks, it never relaxes anything.
- Secrets: the placed node carries the script’s declared
secretslist, and the Code node’s existing secret gating applies. Dropping a custom node grants nothing the same node hand-written would not have. - Scripts are per-user (
loadOwnedin the tRPC router); the palette is too. Sharing a custom node with another user is sharing the workflow (the code travels materialized) or exporting the script — a marketplace is out of scope.
Phases
- Schema + save path.
paletteon the document (Zod, validation rules),ui_jsscript_set_metasupport, the script-editor toggle, and the Code node’s Save to My Nodes action on top ofextractToScript. - Palette.
customNodeMetadata.ts,useCustomNodeMetadata, theinstantiatePaletteNodebranch,codeNodeMode: "custom", the shared materialize helper refactor ofuseCodeNodeScriptLink,NamespaceIconentry. A user can save a node and drop it from the menu. - Agent parity.
paletteinlist_js_scripts, headless materialization through the shared helper, prompt guidance, and an eval case injsscript-toolscovering “save this Code node as a custom node, then use it in a second workflow”.
Each phase lands green alone; phase 2 reads what phase 1 wrote and nothing else.
Testing
- Schema (
packages/protocol/packages/node-sdk):paletteparses and round-trips; the no-outputs warning and empty-category error each go red on a fixture built to violate them. - Web (Jest/RTL):
generateCustomNodeMetadatamaps ports to properties/outputs (typed, defaults, streaming flag); theinstantiatePaletteNodebranch produces a node whose properties and dynamic slots equal whatmaterialize()produces for the same script — asserted by comparing the two outputs, so the shared helper is proven shared; menu refresh onresource_change. - Link freshness: dropping version N, bumping the script to N+1 with a
changed port, and validating reports the mismatch — the existing
jsScriptPortMismatchessuite gains the drop-created fixture. - Harness:
nodetool jsscript validateon a fixture withpalettepasses; the eval case in phase 3 gates on the agent using the saved script rather than re-writing the body.
Out of scope
- Server-side registry entries for custom nodes. Metadata-only
registration exists (
loadMetadata), but the global registry has no per-user scoping and nothing server-side consumes the virtual types today. Ifsearch_nodesshould someday return them, that needs a per-request registry view — its own design. - Sharing and marketplace. Custom nodes travel inside workflows (materialized) and as exported scripts; a catalog of other users’ nodes is a distribution problem this design does not touch.
- Custom icons and colors. Metadata has no icon field; the
usernamespace gets one icon. Per-node art waits until metadata grows it for shipped nodes too. - Non-Code custom nodes (saving a configured LLM node, a subgraph). A subgraph-as-node is a different feature with its own execution semantics; this design deliberately stops at what the Code node can express.