Navigation: Root AGENTS.md → TypeScript Backend Packages
Read docs/DEVELOPMENT_STANDARDS.md first. It is the canonical source for TypeScript, ES modules, Fastify, Drizzle, Zod, testing, error handling, and security standards. The rules below are the area-specific overlay for
packages/.
The packages/ directory contains the TypeScript backend — a set of npm workspace packages that implement the NodeTool workflow runtime, API server, CLI, and supporting libraries.
| Package | Description |
|---|---|
@nodetool-ai/protocol |
Shared message types, Zod schemas, protocol definitions |
@nodetool-ai/config |
Configuration loading and logging utilities |
@nodetool-ai/security |
Secret storage and encryption |
@nodetool-ai/auth |
Authentication middleware and utilities |
@nodetool-ai/storage |
File storage adapters (local, S3) |
@nodetool-ai/models |
Data models via Drizzle ORM — SQLite (local/Electron) and PostgreSQL/Supabase (cloud) |
@nodetool-ai/node-sdk |
BaseNode class, NodeRegistry, node authoring API, type system |
@nodetool-ai/runtime |
ProcessingContext, LLM providers (Anthropic, OpenAI, Gemini, Ollama, etc.), message queue |
@nodetool-ai/kernel |
Workflow graph model, NodeInbox, NodeActor, WorkflowRunner |
@nodetool-ai/agents |
Planning agent system — TaskPlanner, TaskExecutor, CodeActExecutor, Tool registry |
@nodetool-ai/chat |
Chat message processing and token counting |
@nodetool-ai/base-nodes |
Compatibility shell re-exporting the domain node packages (core-nodes, text-nodes, llm-nodes, data-nodes, document-nodes, image-nodes, audio-nodes, video-nodes, integration-nodes, code-nodes, automation-nodes) as ALL_BASE_NODES |
@nodetool-ai/fal-nodes |
FAL AI integration nodes |
@nodetool-ai/fal-codegen |
Code generator for FAL AI node definitions |
@nodetool-ai/replicate-nodes |
Replicate integration nodes |
@nodetool-ai/elevenlabs-nodes |
ElevenLabs TTS integration nodes |
@nodetool-ai/minimax-nodes |
MiniMax TTS, music, image, and video nodes |
@nodetool-ai/huggingface |
HuggingFace model discovery and downloads |
@nodetool-ai/vectorstore |
SQLite-vec vector store for RAG |
@nodetool-ai/websocket |
Fastify HTTP + WebSocket server (main API, port 7777) |
@nodetool-ai/cli |
Command-line interface (nodetool command) |
@nodetool-ai/deploy |
Cloud deployment utilities |
@nodetool-ai/dsl |
Workflow DSL for programmatic workflow creation |
@nodetool-ai/storyboard |
Storyboard derivations — recastStoryboard, planShotRenders, and the one render path under io/ |
@nodetool-ai/game-nodes |
Godot template nodes — read a template's asset slots, prompt one, export the filled project |
@nodetool-ai/app-runtime |
Mini-app document, bindings, instance state, and the streaming fold — dependency-free, shared by web, mobile, and app debug (README) |
Each of these adds package-specific rules on top of this file. Read this file first, then the overlay for the package you are touching.
agents— planning, execution, capabilities, evalsatlascloud-nodes— AtlasCloud API wrapperaudio-nodes— audio editing & DSPautomation-nodes— triggers, OS automation, browser, SQLite pathbase-nodes— core workflow nodesblender-nodes— headless Blender render and mesh preparationdata-nodes— dataframes, filtering, feeds & chartsfal-codegen— FAL manifest generatorgame-nodes— Godot templates, slot prompts, project exportfal-nodes— FAL node factoryimage-nodes— image processing & shaderskie-codegen— KIE config & manifest generatorkie-nodes— KIE node factoryllm-nodes— LLM, image, TTS & agent nodesmodels— persistence layer, tables, migrationsnode-sdk—BaseNode,NodeRegistry, type systemreplicate-nodes— Replicate node factoryruntime—ProcessingContext& LLM providerssecurity— secret storage & encryptionstoryboard— recast a board, plan and run its shot renderstimeline— clip editing mathtopaz-nodes— Topaz API wrappertransformers-js-nodes— local transformers.js inferencevideo-nodes— video editing & assembly
A package with no overlay is governed by this file alone.
All packages use TypeScript and are built with tsc. The recommended way to build is:
# From repo root — builds all packages in dependency order (ALWAYS use this)
npm run build:packages
# Build a single package (only when its dependencies are already built)
npm run build --workspace=packages/websocket# Start the API server in development mode (tsx --watch, auto-restart)
npm run dev:watch:server
# Start both server and web frontend
npm run dev:watch
npm run dev
# Start backend only
npm run dev:watch:server
npm run dev:serverImportant: The dev server uses tsx --watch which runs TypeScript directly. However, base-nodes, node-sdk, fal-nodes, replicate-nodes, elevenlabs-nodes, and minimax-nodes use decorators and load from dist/. If you change these packages, run npm run build:packages first.
Each package has its own test suite using Vitest:
# Run tests for all packages
npm run test:packages
# Run tests for a single package
npm run test --workspace=packages/kernel
# Watch mode for a single package
npm run test:watch --workspace=packages/kernelWindows test portability: build expected OS-local filesystem paths with
node:path (join/resolve). Do not append a hard-coded / to tmpdir() or
another native path; Windows returns \ separators.
The in-browser workflow harness exercises the full backend end-to-end via packages/websocket/src/e2e-server.ts (real runner, in-memory DB, scripted-provider fallback). Run it from web with npm run test:e2e-runner. See web/src/e2e_runner/README.md.
The packages have a strict dependency hierarchy. Lower packages are dependencies of higher ones:
protocol → config → security → auth → storage
↓
runtime → kernel → node-sdk → base-nodes
↓
models → agents → chat
↓
websocket ← cli
This order determines build order. npm run build:packages builds in this order automatically. If you build a single package, its dependencies must already be built.
The main entry point for the backend. Provides:
- REST API endpoints (
/api/workflows,/api/assets,/api/applications, etc.). Jobs, settings, projects, and collection CRUD moved to the tRPC routers insrc/trpc/routers/;POST /api/collections/:name/indexstayed on REST for the multipart upload - WebSocket endpoint (
/ws) for streaming workflow execution (MsgPack serialization) - OpenAI-compatible endpoint (
/v1/chat/completions) - MCP server integration
- Health check endpoint (
/health)
PORT=7777 HOST=127.0.0.1 node packages/websocket/dist/server.jsImplements the workflow execution engine:
WorkflowRunner— executes workflow graphsNodeInbox/NodeActor— actor-model message passing- Graph traversal and topological ordering
Provides adapters for AI providers:
AnthropicProvider,OpenAIProvider,GeminiProviderOllamaProvider,MistralProvider,GroqProviderClaudeAgentProvider— Claude Agent SDK integration (uses Claude subscription, not API key)PythonStdioBridge— calls Python-based nodes (HuggingFace, MLX) via local stdio subprocessProcessingContext— execution context with secret resolution
Multi-step planning agent with:
TaskPlanner— decomposes objectives into a DAG of StepsParallelTaskExecutor/TaskExecutor— walk the DAG, oneCodeActExecutorper stepToolbase class and the capability registry (src/capabilities/)- Skills system — user-scoped records plus the
SKILL.mdfiles shipped inpackages/system-skills/, both reached throughlist_skills/load_skill - See docs/AGENTS.md for full architecture documentation
Base classes and registry for building workflow nodes:
BaseNode— abstract base class for all nodesNodeRegistry— registers and resolves node types- Type system for node inputs/outputs (connections enforce compatible types)
The nodetool command-line interface (packages/cli/src/commands/). Beyond
server/jobs/assets/secrets management, it ships the harnesses an agent uses to
close the build→verify loop. Run from source with npm run dev:nodetool -- <cmd>
(no build) or from dist with npm run nodetool -- <cmd>. Full flags in
CLI documentation and the root AGENTS.md.
| Command | Harness | When |
|---|---|---|
validate <id|file> (validate.ts) |
Static graph check — unknown nodes, missing props, unselected models, unavailable provider/model ids, dangling or mis-typed edges, leftover DSL wiring handles, and unresolved declared credentials on DB-id targets | Cheap pre-flight before any run. Sub-second; no DB for file/DSL targets. Core: validateGraph in node-sdk |
debug <id|file> (debug.ts) |
Run a workflow end-to-end on the headless kernel and bundle every message/log/output/error/trace; --browser adds a real Playwright surface, --stages per-stage shots, --watch a per-save verdict diff |
Run-and-inspect; iterative troubleshooting |
node run <type> --props '{…}' (node.ts) |
Single-node harness — instantiate one node, feed a prop bag, print what it emits; --no-secrets skips the DB |
Isolate one node without authoring a graph |
run <file> / workflows run <id> |
Execute a workflow (id, JSON, or DSL .ts) |
Quick run by id/file |
affected [--base main] (affected.ts) |
Map changed files → minimal workspaces to rebuild/test (owning package + downstream + build:packages only if a decorator package is hit) |
Before reflexively running the full 1–2 min build |
deploy … (deploy.ts) |
Docker/SSH/RunPod/GCP/Supabase deployments + remote workflow sync/run, DB rows, vector collections | Self-host / cloud ops |
--trace-file / --trace-stdout |
OTel span tree (timing, tokens, cost) on any run | Profiling agents/workflows |
The same validate/debug capabilities are exposed to in-product agents as the
validate_workflow / debug_workflow MCP tools in
packages/agents/src/tools/mcp-tools.ts (alongside run_workflow,
create_workflow, search_nodes, get_node_info, job/asset tools). See the
root harness index and
docs/AGENTS.md.
Baseline rules (see DEVELOPMENT_STANDARDS for the full set):
- All packages use ES modules (
"type": "module"in package.json). - TypeScript strict mode is enabled in all packages.
- Test files go in
tests/orsrc/__tests__/. - Each package exports a clean public API via
src/index.ts. - Inter-package imports use workspace references (
@nodetool-ai/...). - Never import from
dist/directories in source code. - Use Vitest for all package tests (not Jest).
- Throw
Errorobjects, not strings. Comment intentionally empty catch blocks.
Backend-specific standards (full detail in DEVELOPMENT_STANDARDS.md):
- Fastify routes declare a
schemafor body/query/params/response — unvalidated payloads are bugs. See §9. - Drizzle schemas live in
models/src/schema/; migrations are generated, not hand-written. No raw SQL when a builder works. See §10. - Zod is the canonical validator. Schemas and types are co-located:
export const Foo = z.object({...}); export type Foo = z.infer<typeof Foo>;. See §11. AbortControlleris mandatory for cancellable async (LLM calls, fetches, subprocesses). Plumb the signal through.- OpenTelemetry spans wrap every external call, every workflow step, every IPC handler. Use
gen_ai.*,http.*,db.*,rpc.*semantic attributes. No PII in spans. See §17. - WebSocket payloads on
/wsare MsgPack, not JSON. Heartbeats every 30s. Backpressure viabufferedAmountchecks. See §13. - No
console.login committedpackages/*/src/. Use the structured logger. - Native
fetch(Node 22) — nonode-fetch/axiosin new code without justification. - Errors at boundaries return discriminated
Resulttypes when expected; throws are for bugs.
packages/* currently has @typescript-eslint/no-explicit-any disabled (transitional). target: zero any in packages/protocol, packages/kernel, packages/runtime, packages/agents, packages/node-sdk. New code must use unknown + narrowing or proper generics.
These cross-cutting rules — distilled from shipped bug fixes — apply to every node you write or edit, whether or not its package has an overlay. The overlays listed above add to, not replace, this list.
- Every key your
process()/genProcess()returns must be a declared output slot inmetadataOutputTypes. The graph editor only exposes declared slots as connectable handles, so a returned key that isn't declared (a strayname,output,row_id, …) is unreachable downstream — the data silently vanishes. This bit automation-, image-, and audio-nodes. - Test that each declared output port is actually populated, and never test a
node only as a terminal sink — sinks collect every returned value regardless
of slot name, so they hide slot-name mismatches. Assert the invariant
"every returned key is a declared slot" (see
automation-nodes/tests/trigger-entry.test.ts). - A streaming output handle (
chunk) must be produced by anasync *genProcessgenerator, andoutputCorrelationmust match:iterationfor streamed chunks,singlefor the final aggregate. Declaring achunkoutput on a node whoseprocess()only returns a final value leaves it permanently empty. - Implement every prop you declare. A declared-but-unwired prop (filter
extensions,include_subdirectories,timeout_seconds,gain_db) is a silent no-op. If you delegate to a shared loader, make sure it honors those props.
datacarries RAW base64 or aUint8Array— never adata:URI. The hot consumers (decodeAssetBytesin websocket,asUint8Arrayin openai-provider) doBuffer.from(data, "base64")with no prefix stripping, so adata:image/png;base64,prefix corrupts every saved/forwarded asset. Put the MIME type incontent_type, not inline indata.- Always include the
typediscriminator ("image"/"audio"/"video"/"model3d") on every ref you emit —isAssetLikeValue, asset auto-save, and downstream type detection all key off it. A bareUint8Arrayoutput is an untyped value; wrap it as{ type, data }. - Use your package's
*Ref/*RefFromByteshelper (imageRef/videoRef,audioRefFromBytes/audioRefFromWav, the minimax*RefFromBytes) instead of hand-rolling the object literal — the helper setstypeand raw-base64datacorrectly. - Read input media bytes with the async, context-aware resolver
(
loadMediaRefBytes(value, context)in runtime, oraudioBytesAsync/videoBytesAsync). The sync readers (audioBytes) see only inlinedataand return empty forasset:///file:///http/storage refs — a node that uses them silently drops media supplied as an asset or URL. - Choosing inline
datavsuri: check.length > 0, never bare truthiness. A zero-lengthUint8Arrayis truthy, soif (ref.data)shadows a perfectly gooduri. Guarddata.length > 0and fall through to URI/asset resolution.
- No
if (end < 0) end = lengthcatch-all. Pick exactly one documented sentinel (e.g.-1= "through the end") and count other negatives from the end Python-style (len + end). Always clamp indices and crop/extract boxes to the valid range before slicing — downstream libs (sharp) throw on out-of-range, and wrappers may swallow the error and silently return the un-cropped input. - Never write
Number(x) !== null/Number(x) !== undefined.Number()returns a number orNaN, never null — the guard is always true and the branch is dead. UseNumber.isFinite(x), or treat the prop default (usually0) as "unset". Test both branches of every bound check. - Evaluate any divisor derived from a prop at the prop's declared
minandmax. If it can reach0or non-finite (e.g.2^(bitDepth-1)-1atbitDepth=1), floor/clamp it. Test the boundary values, not just the default. - Generate evenly-spaced float series as
i * step, not repeated+= step— accumulation drifts for fractional steps (frame ticks at1000/30). - Track "did X happen" with an explicit boolean set at the decision point, not
by inferring
result !== input— that's wrong when the result legitimately equals the input (a snap landing exactly on the cursor).
- Apply defaults as
{ ...options, key: options?.key ?? DEFAULT }— spread first, default last. Spreading...optionsafter a default lets an explicitkey: undefinedclobber it (an explicitundefinedis a present key). - A prop default lives in exactly one place (the descriptor). Inline
?? literalfallbacks must reference that same value, never a hardcoded copy that drifts. Collapse anya ?? a. A default filename's extension must match the bytes actually written. - Treat a logically-unreachable branch as a bug, not dead weight — it almost
always means the intended behavior was silently never executed (e.g. a dead
else ifinside an outer truthyifdropped conditional fields). When a runtime factory mirrors a codegen reference, keep the branch structure identical. - Parse structured binary/markup by structure, not fixed offsets. Walk RIFF
chunks honoring word-alignment padding (
offset += 8 + size + (size & 1)); validate the full magic signature and minimum byte length before indexing; check both byte orders for endian-sensitive formats (TIFFII*/MM\0*); never trust a declared length field from external/streamed data — clamp to bytes present. Read feed fields by element/attribute, not RSS-only assumptions. - Treat empty-string and whitespace-only input as a valid empty case, not just
null/undefined—.trim()beforeJSON.parse/new RegExp/numeric parse. - Never build an expression evaluator with
new Function/withor naive substring.replace()of keywords (it corrupts string literals and makes chained comparisons always-true). Tokenize and parse; throw on invalid input instead of silently returning an empty result. - Release native/GPU handles in a
finallyblock, never on the trailing happy path. Allocate aslet h: T | undefined,h?.destroy()infinally, so a throw during encode/submit/mapAsynccan't leak the handle.
Packages that wrap third-party AI APIs (atlascloud-nodes, topaz-nodes,
replicate-nodes, kie-nodes, fal-nodes, minimax-nodes, …) share a billing
and transport surface where transient errors cost real money. Rules from shipped
fixes:
- Never retry a non-idempotent request (job-creating
POST, state-transitionPATCH) on 5xx — the server may have already acted (and billed) before the error. Retry onlyGET/HEADand idempotentPUTs (presigned uploads). - Always retry the download of a billed result (429/5xx with backoff) — once a job is submitted and billed, a transient CDN blip must not discard the paid-for output. Also retry thrown network errors (ECONNRESET/timeout) for idempotent requests, and drain a discarded response body before retrying so the keep-alive connection is reusable.
- Parse
Retry-Afteras either delay-seconds or an HTTP-date, clamp to>= 0, and fall back to exponential backoff when unparseable. Never feed a rawNumber(header)intosleep— an HTTP-date yieldsNaNand fires immediately. - Centralize terminal poll states in shared SUCCESS/FAILURE sets covering all
synonyms (
fail/failed,cancel/canceled/cancelled,complete/completed/done/succeeded). A terminal status a poll loop doesn't recognize must never silently degrade into a timeout. Don't sleep after the final poll attempt. - Harden SSRF checks on every URL you download: normalize the host to numeric
octets using full
inet_atonsemantics (decimal2130706433, hex0x7f..., octal, short-form127.1) and unwrap IPv4-mapped IPv6 ([::ffff:127.0.0.1]) before range-checking private/loopback/link-local/metadata; blocklocalhostand*.localhost. A dotted-quad regex is not enough (the169.254.169.254metadata IP has many encodings).atlascloud-base.tshas the hardened reference. - Extract output URLs by recursively scanning string /
FileOutput(.url()/.url) / array / named-key-object shapes; accept a nested string only if it matches^(https?:|data:). Don't assume the URL sits under a fixed key. Don't advertise an API option that produces an extra output the node's single-output extractor can't surface. - Detect all-numeric enums and register numeric values + numeric default, and
coerce the API arg back to a number — a
String()cast sends"768"and fails the model's integer schema. - Prune empty args by stripping only top-level
null/undefined/""keys — never recurse into user-supplieddict[...]inputs (you'd mutate their intended shape) and never strip0/false. Returnnull(so arg-cleanup drops it) for an asset that can't be turned into a publicly reachable URL — never hand a remote API a local/relative path. - AssetRefs: read both snake_case
mime_typeand camelCasemimeType(prompt @-mention injection uses camelCase); model a multi-asset input aslist[image]/list[video]/list[audio], not a single asset type witharray: true. - Multipart upload: stop chunking once the source is fully consumed (no
zero-byte trailing parts) and echo correlation IDs (
uploadId) from the accept/initiate step into the complete call. - Wire
AbortSignalinto the runtime's cooperative cancellation hook (e.g. transformers.jsInterruptableStoppingCriteria), not just a post-hocsignal.abortedcheck, and surfaceAbortError(not the internal interrupt error). Clean up the listener.
- Create directory under
packages/<name>/withpackage.json,tsconfig.json,src/index.ts. - Set
"name": "@nodetool-ai/<name>"and"type": "module"in package.json. - Add the workspace path to the root
package.jsonworkspacesarray. - Run
npm installfrom the repo root to link the workspace.npm run build:packagesisturbo run build --filter="./packages/*", which derives build order from each package's declared dependencies — there is no per-package step to add.