---
title: "NodeTool CLI"
description: "The `nodetool` command-line interface — manage servers, workflows, jobs, assets, and secrets from your terminal."
canonical: https://docs.nodetool.ai/cli
markdown: https://docs.nodetool.ai/cli.md
product: NodeTool
source: https://github.com/nodetool-ai/nodetool/blob/main/docs/cli.md
---

# NodeTool CLI

The `nodetool` CLI is the TypeScript command-line interface for the NodeTool platform. It manages servers, workflows, jobs, assets, and secrets. Run `nodetool --help` to see the top-level command list. Every sub-command exposes its own `--help` flag with detailed usage.

## Installation

Install globally from npm to get the `nodetool` and `nodetool-chat` commands:

```bash
npm install -g @nodetool-ai/cli
```

Or run a single command without installing:

```bash
npx --package=@nodetool-ai/cli nodetool --help
npx --package=@nodetool-ai/cli nodetool-chat
```

**Requires Node.js 22 or later** (`engines` in `packages/cli/package.json`, and the repository pins 24 in `.nvmrc`). Check with `node --version`, and install via [nvm](https://github.com/nvm-sh/nvm) if needed.

## Getting Help

- `nodetool --help` — list all top-level commands.
- `nodetool <command> --help` — show command-specific options (e.g. `nodetool serve --help`).
- `nodetool <group> --help` — list sub-commands for grouped tooling (e.g. `nodetool workflows --help`).
- `nodetool help [command]` — the same output as `--help`, as a command (e.g. `nodetool help serve`). With no argument it lists the top-level commands.

## Authoring tools and guides

Use the installed tool catalog and guides to start a document from the terminal.
No agent session or MCP client is needed. These commands run locally against the
same tool implementations as CLI agent sessions.

```bash
nodetool tools list timeline
nodetool tools info create_timeline
nodetool skills list motion
nodetool skills read motion-graphics
nodetool tools call create_timeline --args @arguments.json
```

`tools list [query]` prints names and descriptions. `tools info <name>` prints
its current input schema. `tools call <name> --args <json|@file>` executes it
with a JSON object. Results are JSON. Tool failures exit nonzero. Calls can save
work, execute jobs, or incur provider charges. The local database, secret store,
installed packs, current working directory, and agent budget settings are used
as in CLI agent sessions. Generation costs are recorded in the local ledger.

`skills list [query]` searches installed guides and user skills.
`skills read <name>` reads the full guide, including its examples and creation instructions.
Start with these guides and then inspect the named tools:

| Document | Guide | Tool discovery |
| --- | --- | --- |
| Timeline or storyboard | `storyboard-core` | `tools list timeline` |
| Animated text and shapes | `motion-graphics` | `tools list timeline` |
| Reusable workflow | `nodetool-workflow-builder` | `tools list workflow` |
| Interactive mini app | `nodetool-app-builder` | `tools list app` |
| Playable game | `native-game` | `tools list game` |

Call `nodetool tools call list_projects` to obtain an owned project id for
project-scoped documents. Local tool calls initialize the Personal project
(`personal:1`) on a fresh installation.

For a deterministic mini app build without provider charges, run:

```bash
nodetool packs add audio --yes
nodetool app build "greeting fixture" --provider fake --model fake-app-greeting --no-judge
```

The audio pack supplies the required `nodetool.output.Output` node.
`fake-app-greeting` builds a fixed greeting app and ignores the prompt. It tests
the build pipeline and interaction checks, not general app generation.

For games, `list_example_games` and `get_example_game` expose shipped starting
documents. Follow the guide's installation instructions when a bundled example
uses packaged assets. For other documents, read the guide's minimal example and
inspect the creation tool's schema before saving it.

## Global Options

These flags work on any `nodetool` command and control [OpenTelemetry tracing](https://github.com/nodetool-ai/nodetool/blob/main/docs/AGENTS.md):

- `--trace-file <path>` — append every LLM/agent/workflow span to `<path>` as JSONL (analyzer-friendly).
- `--trace-stdout [format]` — stream spans to stdout: `pretty` (default) or `json`.
- `--no-trace-stdout` — disable stdout span output (overrides `NODETOOL_TRACE_STDOUT`).
- `--version` — print the CLI version.

```bash
nodetool --trace-file trace.jsonl run workflow.ts
nodetool --trace-stdout pretty workflows run <id>
```

## Core Commands

### `nodetool info`

Display the CLI version, Node.js version, platform, which provider API keys are set (`configured` or `not set`, never the value), and the `ENV`, `LOG_LEVEL` and `PORT` environment values.

**Options:**

- `--json` — output as JSON.

**Example:**

```bash
nodetool info
nodetool info --json
```

### `nodetool serve`

Starts the TypeScript WebSocket + HTTP backend server. This serves the REST API, WebSocket endpoints, and static assets.

**Options:**

- `--host` (default `127.0.0.1`) — bind address (use `0.0.0.0` for all interfaces).
- `--port` (default `7777`) — listen port.

**Examples:**

```bash
# Start the server on the default port
nodetool serve

# Bind to all interfaces on a custom port
nodetool serve --host 0.0.0.0 --port 8080
```

`serve` passes the two flags to the server as `HOST` and `PORT`, overwriting whatever those variables already held — use the flags, not the environment, to move the server.

### `nodetool workflows run <workflow_id_or_file>`

Executes a workflow by ID (from the local database), JSON file, or TypeScript DSL file.

**Arguments:**

- `<workflow_id_or_file>` — workflow ID, path to a `.json` workflow file, or path to a `.ts` DSL file.

**Options:**

- `--params <json>` — JSON string of workflow parameters.
- `--json` — output result as JSON.
- `--asset-output-mode <mode>` — how media outputs are materialized: `native` (default), `raw`, `data_uri`, `workspace`, `storage_url`, or `temp_url`.
- `--workspace <dir>` — workspace directory for `workspace` mode output (default `./nodetool-output`).
- `--trigger-event <json>` — wake a trigger node the way the scheduler or webhook adapter would, for example `'{"node_id":"watch","payload":{"path":"/tmp/a.csv","event":"created"}}'`. Without it a webhook, file-watch or manual trigger has no event to emit and the run stalls until its timeout.
- `--supervise` and its bounds — see [Supervised runs](#supervised-runs).

**Examples:**

```bash
# Run workflow by ID
nodetool workflows run workflow_abc123

# Run workflow from JSON file
nodetool workflows run ./my_workflow.json

# Run workflow from TypeScript DSL
nodetool workflows run ./my_workflow.ts

# Run with parameters as JSON
nodetool workflows run workflow_abc123 --params '{"input": "hello"}'

# JSON output for automation
nodetool workflows run ./my_workflow.json --json
```

`--json` never expands a large binary output into the JSON text. An image,
audio, or video payload over 64 KiB is written to
`nodetool-output/<job_id>/payload-N.<ext>` and appears as
`{"$file": "…", "bytes": N, "mimeType": "…"}` in its place; the path is
reported on stderr. Image nodes emit raw RGBA as their in-flight format, so a
raw ref is PNG-encoded on the way out. `nodetool run` and `nodetool node run`
do the same, spilling into `nodetool-output/`.

### `nodetool workflows export-dsl <workflow_id_or_file>`

Exports a workflow as a TypeScript DSL file.

**Arguments:**

- `<workflow_id_or_file>` — workflow ID or path to a `.json` workflow file.

**Options:**

- `--api-url <url>` — fetch the workflow from a remote server instead of the local database (default `NODETOOL_API_URL`).
- `-o, --output <file>` — write to file instead of stdout.

**Examples:**

```bash
# Print DSL to stdout
nodetool workflows export-dsl workflow_abc123

# Write to file
nodetool workflows export-dsl workflow_abc123 -o workflow.ts

# Export from JSON file
nodetool workflows export-dsl ./my_workflow.json
```

### `nodetool workflows export-script <workflow_id_or_file>`

Converts a workflow into a JavaScript script that calls each node in
dependency order, one awaited call per node. The editor's workflow menu and
the `workflows.exportScript` tRPC call produce the same script.

**Options:**

- `-t, --target <target>` — `sandbox` (default) writes a body for a JS script
  document or a Code node, calling nodes through
  `@nodetool-ai/sandbox-nodetool/flow`. `node` writes a standalone `.mjs` that
  calls nodes in process through `@nodetool-ai/dsl/flow`, takes the workflow
  inputs as a JSON argument, reads provider keys from the environment, and
  prints the outputs as JSON.
- `-o, --output <file>` — write to file instead of stdout.

A node a script cannot express (a node that runs once per streamed item, a
control edge, a live input stream) is listed on stderr, and the script throws
before running any node. A graph with a cycle, such as a `Loop`, is refused.

```bash
nodetool workflows export-script workflow_abc123 --target node -o flow.mjs
npm install @nodetool-ai/dsl
node flow.mjs '{"prompt": "a red fox"}'
```

### `nodetool run <dsl-file>`

Shorthand for running a TypeScript DSL workflow file directly.

**Options:**

- `--json` — output results as JSON.
- `--supervise` and its bounds — see [Supervised runs](#supervised-runs).

**Examples:**

```bash
nodetool run workflow.ts
nodetool run workflow.ts --json
```

## Supervised runs

`--supervise` puts an agent on the failure path: when a node invocation throws
after its own error handling is exhausted, the agent sees the failure and
answers with one verdict — retry, repair the output, skip the item, or fail.
Without the flag nothing changes: no escalation is constructed and the run is
the run it was before.

Available on `nodetool run`, `nodetool workflows run`, and `nodetool debug`
(server surface).

**Options:**

- `--supervise` — supervise this run. Off unless passed.
- `--max-decisions <n>` — decisions allowed in the run (default 10).
- `--max-retries <n>` — retries per node invocation (default 2).
- `--supervisor-cost-cap <usd>` — ceiling on supervisor spend (default 0.50),
  enforced by reservation before each model turn, not after.
- `--supervisor-model <provider/model>` — who supervises (default
  `anthropic/claude-sonnet-4-6`, or `NODETOOL_SUPERVISOR_MODEL`). The leading
  segment must be a registered provider; the rest is the model id, slashes and
  all (`openrouter/openai/gpt-5.4-mini`).

Passing a bound without `--supervise` is an error rather than a silent
unsupervised run.

**Output.** Each decision prints a `⛨` line as it happens, and the run ends
with a supervised summary:

```
⛨ fetch-item [3] skipped — HTTP 404 (agent, $0.0041)
⛨ supervised: 2 skipped, 1 retried, 3 decisions, +$0.0200
```

With `--json`, the decisions are in `interventions` — alongside the outputs in
`workflows run`, and under `{results, interventions}` in `nodetool run`, whose
bare results shape is left alone for unsupervised runs. `nodetool debug` puts
them in `server.summary.interventions` with a `server.supervised` rollup. Each
record carries the
escalation the agent saw (node, item lineage, redacted inputs, allowed
actions), the verdict, who decided it (`agent`, `sticky`, `bounds`, `default`,
`kernel`), and its cost.

**Cost.** Supervisor spend lands in the same ledger `nodetool costs` reads, one
row per billable decision, attributed to the run and tagged `supervisor` in
`node_type` — so supervision is separable from the workflow's own spend:

```bash
nodetool costs list --limit 20        # supervisor rows show node_type=supervisor
```

**Bounds are the guarantee.** Every supervisor failure (timeout, an unparseable
verdict, an exhausted budget, a cancelled run) resolves as `fail`, which is
what would have happened without it. What each verdict means and why
retry is opt-in per node: [workflow-supervisor-design.md](workflow-supervisor-design.md).

## Database Migrations

### `nodetool db migrate`

Applies NodeTool migrations to a PostgreSQL/Supabase database. For Supabase, use the **direct connection URL** from Settings → Database (port `5432`), not the transaction pooler URL.

**Options:**

- `--direct-url <url>` — Supabase/PostgreSQL direct connection URL.
- `--database-url <url>` — connection URL; defaults to `DIRECT_URL` or `DATABASE_URL`.
- `--target <version>` — stop after a specific migration version.
- `--dry-run` — show pending migrations without applying them.
- `--skip-checksums` — skip checksum validation.
- `--json` — output as JSON.

**Examples:**

```bash
DIRECT_URL="postgresql://postgres:[password]@db.[project].supabase.co:5432/postgres" \
  nodetool db migrate

nodetool db status --direct-url "$DIRECT_URL"
nodetool db migrate --direct-url "$DIRECT_URL" --dry-run
```

Other migration commands:

```bash
nodetool db status   --direct-url "$DIRECT_URL"
nodetool db baseline --direct-url "$DIRECT_URL"   # for existing DBs
nodetool db rollback --direct-url "$DIRECT_URL" --steps 1
```

`status`, `baseline`, and `rollback` take `--direct-url`, `--database-url`, and `--json` like `migrate`. `baseline` also takes `--force` to clear and recreate the migration tracking records. `rollback --steps <n>` defaults to `1`.

## Chat

### `nodetool chat`

Starts fullscreen terminal chat with a scrollable transcript, multiline composer,
tool details, task progress, and approval prompts. The previous terminal screen
is restored on exit. Piped input keeps its line-based interface.

**Options:**

- `-p, --provider <provider>` — LLM provider (e.g., `anthropic`, `openai`, `ollama`).
- `-m, --model <model>` — model ID.
- `-a, --agent` — **deprecated, no-op.** Every chat session runs the unified agent loop; this flag has no effect.
- `-u, --url <url>` — WebSocket server URL (default: uses a local provider).
  With piped input, the command exits 1 when the server reports an error for
  any turn.
- `-w, --workspace <path>` — workspace directory for file operations (default: current directory).
- `--no-read-only-search` — disable the read-only `run_search` fan-out primitive (on by default).
- `--tools <tools>` — comma-separated tool names that narrow the belt. Without
  it, a local session offers the same belt as a server chat turn.
- `--permission-mode <default|auto|plan>` — how tool calls are gated
  (see [Permission mode](#permission-mode)). Interactive chat defaults to
  `default`, with approval prompts. Piped input defaults to `auto`.
- `--resume [id]` — resume a saved conversation in this workspace. Without an
  ID, resume the most recently saved session.
- `--cost-cap <usd>` — ceiling on provider spend for one turn; `0` lifts it.
  Default: the `NODETOOL_AGENT_TURN_COST_CAP_USD` setting.
- `--timeout <s>` — wall-clock bound on one turn, in seconds; `0` leaves it no
  time at all. Default: the `NODETOOL_AGENT_TURN_DEADLINE_MS` setting.

Both flags override two of the five `NODETOOL_AGENT_*` settings; the other
three — concurrency, total turns, unpriced-token ceiling — come from the
settings alone. The budget is one object per turn, shared by every loop the
turn starts, so a ceiling bounds the turn rather than each loop separately. A
turn a ceiling refuses prints the reason: `[stopped] turn budget of $5 reached`
piped, or a `Stopped:` line in the TUI. The terminal also displays the local
turn's provider spend. Connected servers manage their own run budgets.

Fullscreen chat reserves stdout for its screen. Use `--trace-file <path>` for
tracing and `--no-trace-stdout` when stdout tracing is enabled in the environment.

Enter sends a message. Alt+Enter, Shift+Enter in supported terminals, or Ctrl+J
inserts a newline. Pasted lines stay in the composer. Up/Down recalls prompts,
Tab completes commands. Page Up/Page Down and the mouse wheel scroll the
transcript. Drag across transcript text to select and copy it to the clipboard
on release. Ctrl+G returns to the latest output. Ctrl+O toggles tool details
and edit diffs.
Escape stops a running turn or dismisses input. Ctrl+C stops a turn, clears a
draft, or saves and quits when idle with an empty composer.

Use `/sessions` and `/resume <id>` to continue a conversation, `/export <path.md>`
to save a transcript, and `/mode` to change permissions. `/clear` clears the
screen while retaining context. `/new` starts a fresh conversation. Local
`/compact [instructions]` retains a summary. Remote context is managed by the
server. `/help` lists all commands.

Sessions are saved under `~/.nodetool/chat-sessions/` after turns and on exit.
They include conversation context and provider continuation state. The session
picker filters by workspace and server. Model and provider selections persist
in `~/.nodetool/chat-settings.json`. Remote `/model <id>` accepts a server model
ID without requiring a local API key.

**Examples:**

```bash
# Start interactive chat
nodetool chat

# Chat with a specific provider and model
nodetool chat --provider anthropic --model claude-sonnet-5

# Connect to a running server
nodetool chat --url ws://localhost:7777/ws
```

## Workflow Management

### `nodetool workflows`

Manage workflows. Reads the local database by default; `--api-url` targets a remote server.

**Subcommands:** `list` and `get` (both take `--api-url <url>` and `--json`, and `list` takes `--limit <n>`, default 100), `run`, `export-dsl`, `export-script`, `export-example`, `export-bundle`, `import-bundle`,
`migrate-code-inputs`

```bash
# List all workflows
nodetool workflows list
nodetool workflows list --api-url http://localhost:7777 --json

# Get a workflow by ID
nodetool workflows get <workflow_id>

# Run a workflow (see above for full options)
nodetool workflows run <workflow_id_or_file>

# Export as a TypeScript DSL file (see above)
nodetool workflows export-dsl <workflow_id_or_file>
```

#### `nodetool workflows export-example <workflow_id_or_file>`

Export a workflow as a shipped template: materialize its referenced assets into the package's constant asset directory
(rewriting refs to `package://<pkg>/<file>`) and write the example JSON.

**Options:**

- `--api-url <url>` — fetch the workflow and asset bytes from a remote server instead of the local database (default `NODETOOL_API_URL`).
- `--package <name>` — owning package (default `nodetool-base`).
- `--assets-dir <dir>` — root holding `<package>/<file>` constant assets (default: the monorepo base-nodes assets directory).
- `--examples-dir <dir>` — directory the example JSON is written under, as `<dir>/<package>/<name>.json`.
- `-o, --output <file>` — write the example JSON to this exact path.
- `--include-remote` — also materialize http(s) and local-file refs.

```bash
nodetool workflows export-example <workflow_id>
nodetool workflows export-example <id> --package nodetool-base
nodetool workflows export-example workflow.json -o example.json
```

#### `nodetool workflows export-bundle <workflow_id_or_file...>`

Export one or more workflows as a portable `.nodetool` bundle (a zip containing the graphs plus the bytes of every asset
they reference), sharable as a single file.

**Options:**

- `--api-url <url>` — fetch the workflows and asset bytes from a remote server instead of the local database (default `NODETOOL_API_URL`).
- `-o, --output <file>` — output path (default `<name>.nodetool`).
- `--include-remote` — also embed http(s) and local-file refs.

```bash
nodetool workflows export-bundle <id> [<id2> ...] -o my-pack.nodetool
```

#### `nodetool workflows import-bundle <bundle_file>`

Import a `.nodetool` bundle into the local library: store its assets and create the workflows with refs rewritten to the
imported assets.

**Options:**

- `--json` — print the created workflows as JSON.

```bash
nodetool workflows import-bundle my-pack.nodetool
```

#### `nodetool workflows migrate-code-inputs`

A one-time repair for saved `nodetool.code.Code` bodies. A Code node's declared
inputs used to arrive as globals of their own name; they now arrive on one
`inputs` object, so a body written the old way throws a `ReferenceError` on its
first input read. This walks the saved workflows and rewrites `name` to
`inputs.name` for every name the node can read — its declared slots, its inline
dynamic properties, and any handle an edge feeds it.

The rewrite is done on the AST, so a name inside a string, a comment, an object
key, or a local binding is left alone. It is safe to re-run: a body already
reading `inputs.*` has nothing to rewrite.

**Options:**

- `--dry-run` — report what would change and write nothing.
- `--user-id <id>` — migrate this user's workflows instead of the local user's (`1`).
- `--json` — print the report as JSON rather than the per-node lines.

```bash
# See what would change first
nodetool workflows migrate-code-inputs --dry-run

# Then do it
nodetool workflows migrate-code-inputs
```

Each rewritten node prints one line naming the inputs it moved, followed by a
count:

```
4 Code node(s) in 3 workflow(s) were rewritten (11 scanned across 7 workflows).
```

A body that fails to parse is counted under a trailing `N failed.` line and left
untouched, so one bad node does not stop the pass.

### `nodetool validate <workflow_id_or_file>`

Check a workflow against the node registry **without running it**: unknown node
types, missing required properties, dangling or mis-typed edges, model
properties naming a provider or model id that does not exist,
and `nodetool.code.Code` bodies that never return or leave an output unset. On
a workflow-id target, where the secret store is reachable, it also warns about
declared credentials the install cannot resolve. It finishes in well under a
second, which makes it the cheap pre-flight before a run that costs money.

The target is a workflow id, a workflow JSON file, or a TypeScript DSL file.
File targets need no database.

**Options:**

- `--json` — print the full validation report as JSON.
- `--warnings-as-errors` — exit non-zero on warnings, not just errors.

**Examples:**

```bash
nodetool validate <workflow_id>
nodetool validate workflow.json
nodetool validate workflow.ts --json
nodetool validate <workflow_id> --warnings-as-errors
```

A clean graph prints a one-line verdict and exits `0`:

```
✅ Workflow is valid — 3 node(s), 2 edge(s).
```

A broken one names the node and the rule it broke, then exits `1`:

```
❌ Workflow has 1 error(s).

  error Unknown node type "nodetool.text.NoSuchNode" (not in the registry; Python-only nodes are not validated statically) [nodetool.text.NoSuchNode 1] (unknown_node)

  Codes:
    unknown_node — node type is not in the registry
```

### `nodetool debug <workflow_id_or_file>`

Run a workflow end to end and collect everything it emitted into a
self-contained bundle — every message, log line, node input/output, and error —
then print a verdict. Reach for it when `validate` passes but the run still does
the wrong thing.

The headless server run is on by default. The expensive surfaces are opt-in:
`--browser` starts Playwright and Chromium, `--trace` adds OpenTelemetry
spans, and `--stages` screenshots the canvas at each run stage.

**Options:**

- `--no-server` — skip the headless server surface.
- `--browser` — also run the workflow in a real browser (Playwright).
- `--trace` — capture an OpenTelemetry trace of the server run (timing, tokens, cost).
- `--stages` — screenshot the canvas at every browser run stage (implies `--browser`).
- `--params <json>` — JSON params keyed by input-node name.
- `--out <dir>` — bundle output directory (default `nodetool-debug/<id>-<timestamp>`).
- `--timeout <ms>` — per-surface run timeout.
- `--json` — print the full `DebugReport` as JSON to stdout.
- `--watch` — re-run on file change and print a diff of the verdict (file targets only).
- `--supervise` and its four bounds — see [Supervised runs](#supervised-runs) above.

**Examples:**

```bash
# Server surface only
nodetool debug <workflow_id>
nodetool debug workflow.json --params '{"prompt":"hi"}'

# Opt into the expensive surfaces
nodetool debug <workflow_id> --trace
nodetool debug <workflow_id> --browser --stages

# Tight edit→verify loop on a file target
nodetool debug workflow.json --watch
```

The bundle holds `report.json` and `report.md`, the resolved `workflow.json`,
and `server/messages.jsonl`. `--trace` adds `server/trace.jsonl`; `--browser`
adds `browser/record.json`, a canvas screenshot, and `browser/console-errors.log`.

### `nodetool node list [query]`, `nodetool node info <node_type>`

List executable TypeScript nodes from installed packs, optionally filtering by
node type or title. `--json` returns complete metadata. `info` prints the exact
node type's properties, defaults, inputs and outputs as JSON. These commands
use the same installed pack registry as `node run` and work outside a checkout.
Catalog entries for missing packs are excluded.

```bash
nodetool node list text
nodetool node list --json
nodetool node info nodetool.text.Concat --json
```

Use `nodetool packs list` to inspect pack installation state. Python package
metadata is inspected separately through `nodetool package list`.

### `nodetool node run <node_type>`

Instantiate one node, hand it a property bag, and print what it emits — no
workflow, no graph. Use it when `debug` says a run went wrong and you want to
know whether one node is the reason.

**Options:**

- `--props <json>` — JSON object of property values keyed by `@prop` name (default `{}`).
- `--no-secrets` — skip secret and asset resolution, so the run touches no database.
- `--json` — print the full run result as JSON.

**Examples:**

```bash
nodetool node run nodetool.text.Concat --props '{"a":"hi ","b":"there"}'
nodetool node run nodetool.text.Concat --props '{"a":"hi ","b":"there"}' --no-secrets
nodetool node run nodetool.text.Concat --props '{"a":"hi ","b":"there"}' --json
```

The verdict names the node type, its title, and how long it took, then lists
every record it emitted:

```
✅ nodetool.text.Concat (Concat) — 0ms

Emitted 1 record(s):
  {"output":"hi there"}
```

## Mini App Management

### `nodetool apps`

Move mini apps between installs. Reads and writes the local database directly — no running server.

**Subcommands:** `list`, `export-bundle`, `export-project`, `import-bundle`

```bash
# List applications with their operation count
nodetool apps list
nodetool apps list --json
```

`list` prints `id`, `name`, `operations`, and `updated_at`.

#### `nodetool apps export-bundle <application_id>`

Export an application as a portable bundle: the app document plus the full graph of every workflow its operations bind,
in a single JSON file. Inside the file an operation's `workflowId` is a bundle-local key, not a real workflow id.

**Options:**

- `-o, --output <file>` — output path (default `<name>.app.json`, with unsafe characters replaced by `_`).
- `--released` — export the released snapshot and the graphs it pinned, not the draft. Fails when the app has no
  released version.

The bundle path goes to stdout and the summary line to stderr, so `$(nodetool apps export-bundle <id>)` captures the
path alone. An operation binding a workflow that no longer exists is left unresolved in the bundle and reported as a
warning.

```bash
nodetool apps export-bundle <application_id>
nodetool apps export-bundle <application_id> -o my.app.json --released
```

#### `nodetool apps export-project <application_id|bundle_file>`

Export an application as a standalone JavaScript project that runs without NodeTool: a Vite + React UI and a Node
server that runs the app's workflows and JS scripts in process, with no database. Takes an application id from the local
database or an exported `.app.json` bundle. See [`@nodetool-ai/app-export`](https://github.com/nodetool-ai/nodetool/blob/main/packages/app-export/README.md) for what
the project contains.

**Options:**

- `-o, --output <dir>` — output folder (default `./<app-name>`).
- `--released` — export the released snapshot and the graphs it pinned, not the draft.
- `--force` — write into an output folder that is not empty.

`.env.example` lists the credentials the graphs and scripts need. When a graph leaves a model for the person to pick, it also lists
every provider key, commented out. Sample inputs referenced as `package://` are copied under `assets/`. What the
exported app cannot do (resource documents, editor-only widgets) is printed as warnings and listed
in the project's README.

```bash
nodetool apps export-project <application_id> -o ./my-app
nodetool apps export-project my.app.json
cd my-app && npm install && cp .env.example .env && npm run dev
```

#### `nodetool apps import-bundle <bundle_file>`

Import an application bundle into the local library: create its workflows and the app, with operations rewired to the
new ids.

**Options:**

- `--project <id>` — project to create the app in (default `default`).
- `--json` — output the created application as JSON.

A bundled workflow carrying a `sourceId` gets a row id derived from it, so importing two bundles that ship the same
workflow — two example apps binding one template — reuses the existing row instead of duplicating it. Without a
`sourceId` every import creates fresh workflows.

```bash
nodetool apps import-bundle my.app.json
nodetool apps import-bundle my.app.json --project my-project --json
```

### `nodetool app`

Where `nodetool apps` moves finished apps around, `nodetool app` runs one and
builds one. Both subcommands are headless — no browser, no editor — and both
write a debug bundle under `nodetool-debug/`.

#### `nodetool app debug <application_id_or_file>`

Run a mini app the way the web runtime would: validate every widget binding
against the workflows it binds, seed input defaults, apply params, replay a
scripted interaction sequence, execute the workflows on the kernel, fold the
streamed messages into the app's reactive values, and report what each widget
ends up showing.

The target is an application id (read from the applications table), an
`ApplicationBundle` JSON file (self-contained — operations reference bundle
keys, so it runs without a database), or a workflow id/file carrying a legacy
`app_doc`.

**Options:**

- `--params <json>` — reactive values applied before interactions, keyed by input
  name. A `resource:<binding id>` key seeds that collection with an array of items.
- `--interact <json>` — scripted steps: `set`, `change`, `click`, `run` (by
  operation id), `cancel`, `seedResource`.
- `--no-run` — static wiring check only; never execute a workflow.
- `--out <dir>` — bundle directory (default `nodetool-debug/app-<id>-<timestamp>`).
- `--timeout <ms>` — per-run timeout.
- `--json` — print the full `AppDebugReport` to stdout.

**Examples:**

```bash
# Static wiring check — no provider calls, no workflow execution
nodetool app debug my.app.json --no-run

nodetool app debug <application_id> --params '{"prompt":"hi"}'
nodetool app debug <application_id> --interact \
  '[{"set":{"key":"prompt","value":"hi"}},{"run":"draft"}]'
```

The verdict catches what a workflow-only run cannot: a widget bound to a token
the workflow has no input, output, node, or variable for, one naming an
operation the document never declares, an unknown widget type, a display widget
that never received a value, an app no interaction ever ran, an operation that
overran its timeout, and a click or change on a widget its own `visibleWhen` /
`disabledWhen` hides or disables — that step fails and names the condition.
Every declared operation runs, not just the first, and state is keyed per
operation.

The report lists what it did not simulate under `notSimulated`: layout, styling,
focus, and scroll, since nothing renders a DOM; the stored resource collections,
since a run reads the seeded in-memory provider rather than the database; and
reactive subgraph runs, where the browser reruns one input's downstream subgraph
and the harness runs the whole workflow.

#### `nodetool app build <prompt_or_spec_file>`

Turn a prompt, or a hand-written `spec.json`, into a verified
`ApplicationBundle`. Six stages run in order: **spec** pins what the app must
do, **plan** builds one workflow per operation, **author** places and wires the
widgets through the real `ui_app_*` tool contract, **check** validates the
wiring, **run** replays every interaction on the kernel, and **judge** asks a
model whether each interaction achieved what was asked. Everything still wrong
at the end of a pass becomes one complaint, and the next round edits the
document rather than rebuilding it.

Plan, author, and judge each call a model, and run executes real workflows, so a
build spends money and takes minutes.

**Options:**

- `-p, --provider <name>` / `-m, --model <id>` — the builder's provider and model.
- `--judge-model <provider/model>` — model that judges each interaction (env
  `NODETOOL_APP_JUDGE_MODEL`). Defaults to a configured model other than the
  builder's, because a model grading its own work is the weakest reviewer available.
- `--workflow <id>` — pin an existing workflow instead of planning one
  (repeatable, in operation order).
- `--max-repairs <n>` — repair rounds after the first pass (default `3`).
- `--cost-cap <usd>` — ceiling on build spend (default `2`).
- `--timeout <ms>` — wall clock for the whole build (default `600000`).
- `--out <dir>` — bundle directory (default `nodetool-debug/app-build-<slug>-<timestamp>`).
- `--json` — print the full `BuildReport` to stdout.
- `--no-judge` — skip the judge stage. The verdict's `notSimulated` then says
  nothing scored the app.
- `--watch` — re-build on spec-file change and print a diff of the verdict
  (spec-file targets only). Every save spends money.
- `--supervise`, `--max-decisions <n>`, `--max-retries <n>`,
  `--supervisor-cost-cap <usd>`, `--supervisor-model <provider/model>` — the
  same flags described under [Supervised runs](#supervised-runs). They apply to
  the Run stage, whose interactions execute on the kernel; `buildApp` itself is
  never supervised.

**Examples:**

```bash
nodetool app build "an app that drafts a note from a prompt" \
  -p anthropic -m claude-sonnet-5

nodetool app build spec.json -p openai -m gpt-5.4-mini --json

# Bind an existing workflow instead of planning one
nodetool app build "..." -p anthropic -m claude-sonnet-5 --workflow <id>

# Structural check only, one repair round, tight cap
nodetool app build spec.json -p anthropic -m claude-sonnet-5 \
  --no-judge --max-repairs 1 --cost-cap 1.00
```

The bundle holds `report.json`, `report.md`, `spec.json`,
`interactions/<name>/run-N.messages.jsonl` per replayed run, and — only for a
green build — `app.bundle.json`, the deliverable. The loop fails closed: an
exhausted budget, an issue that reappears after being fixed, or a cancelled
signal ends the build as failed with the reason named, and there is no bundle
behind a failed verdict. Exit code is `0` only when `verdict.ok`.

## Editor Documents

Timelines, sketches, and JS scripts each get the same three commands:
`validate` checks the document without rendering or running it, `debug` replays
a scripted edit session against it, and `versions` reads and writes its snapshot
history. All of them take a JSON file or a row id — a path that exists on disk
wins over an id, and file targets need no database.

### `nodetool timeline`

**Subcommands:** `validate`, `debug`, `render`, `score`, `versions`

#### `nodetool timeline validate <timeline_id_or_file>`

Check a timeline sequence without rendering it: clips on tracks the document
does not have, fields a schema round trip would strip, animation presets that
do not exist, and timings nothing can render. The target is a timeline JSON
file — a bare `TimelineDocument` or anything carrying one under `document`, so
a `timeline.get` response works as-is — or a `timeline_sequences` row id.

**Options:**

- `--json` — print the full `TimelineValidation` as JSON.
- `--warnings-as-errors` — exit non-zero on warnings, not just errors.

```bash
nodetool timeline validate sequence.json
nodetool timeline validate <timeline_id> --json
nodetool timeline validate <timeline_id> --warnings-as-errors
```

```
✅ 0 error(s), 0 warning(s)
```

Every finding carries a stable `code`. An **error** is a document no reading of
which produces the scene its author described; a **warning** is a picture that
renders and is not the one that was written — usually because this build could
not read something a newer one wrote (forward compatibility), or because the
authored motion did not fit the clip.

| Code | Severity | What it caught |
|---|---|---|
| `schema_invalid` | error | The document does not parse; every structural check is skipped |
| `duplicate_id` | error | Two tracks, clips or markers share an id |
| `clip_track_missing` | error | A clip sits on a track the document does not declare |
| `negative_timing` | error | A clip or marker starts before the origin, or a clip lasts zero |
| `fade_exceeds_duration` | error | The in and out audio fades overlap |
| `in_out_points_invalid` | error | The source span is negative or empty |
| `speed_multiplier_invalid` | error | A non-positive playback rate |
| `unknown_animation_preset` | error | A preset this build does not ship; nothing animates |
| `custom_animation_invalid` | error | Baked curves the one gate refuses; re-bake from the script |
| `animation_link_source_missing` | error | A clip links animation to a clip the document lacks |
| `animation_style_invalid` | error | An animation uses glyph tracks without a stagger unit |
| `text_path_invalid` | error | A text clip's path cannot be read |
| `shape_path_invalid` | error | A path shape this build cannot draw, so it draws nothing |
| `midi_clip_off_midi_track` | error | A clip carrying notes sits on a non-midi track, so it makes no sound |
| `non_midi_clip_on_midi_track` | error | A picture or audio clip sits on a midi track, so it is neither heard nor seen |
| `midi_clip_retimed` | error | A midi clip carries speed or time remap, which retime samples a midi clip does not have |
| `midi_notes_invalid` | error | A midi clip holds a note that fails its checks |
| `parent_cycle` | error | A `parentId` chain loops, so the group cannot be resolved |
| `matte_source_missing` | error | A `matte` names a clip the document lacks, or itself — the layer draws unmatted, showing everything the matte was hiding |
| `time_remap_not_monotonic` | error | `timeRemap` keyframe `t` repeats or goes backwards (`sourceMs` may descend — that is a reverse) |
| `field_stripped` | warning | A field the schema drops, lost on the next save |
| `in_out_duration_mismatch` | warning | Source span and timeline duration disagree at the clip's rate |
| `transition_exceeds_duration` | warning | The cut is longer than the clip carrying it |
| `unknown_transition` | warning | A transition `type` or `direction` this build cannot draw; it cross-fades left |
| `unknown_easing` | warning | An `easing` outside the grammar; it eases linearly |
| `generated_matte_stale` | warning | A `generatedMatte` cut from an asset the clip no longer plays, or from less source than its window now shows; regenerate it |
| `unknown_effect` | warning | A clip effect type this build cannot apply; the layer draws ungraded |
| `mask_path_invalid` | warning | A mask `kind` or path `d` that cannot rasterize; the layer draws unmasked |
| `unknown_shape_kind` | warning | A `shapeStyle.kind` this build has no geometry for; the shape draws nothing |
| `font_not_portable` | warning | A font family NodeTool does not ship; every host resolves it against its own installed fonts, so the editor preview and the render can differ |
| `matte_source_invalid` | warning | A clip is matted by an adjustment clip, which has no pixels, so the layer draws unmatted |
| `crop_degenerate` | warning | A crop whose left and right, or top and bottom, sum to 1 or more, so the clip draws its whole source |
| `bake_stale` | warning | A bake rendered from a style the clip no longer has, so the live 3D layer draws instead |
| `reframe_stale` | warning | Automatic framing that no longer matches the active source, so rendering falls back to centred framing |
| `source_curve_outside_window` | warning | Animation keyframes cut from source outside the span the clip plays |
| `adjustment_no_effects`, `adjustment_treats_nothing`, `adjustment_group_empty` | warning | An adjustment clip with no enabled effect, with no clip beneath it in its window, or in a group where nothing else overlaps it |
| `adjustment_field_ignored` | warning | An adjustment clip carries fields it never draws |
| `layout_on_non_group`, `unknown_layout_display`, `flex_item_without_flex_parent` | warning | A `layout` on a clip that is not a group, a `layout.display` other than `flex`, or a `flexItem` whose parent is not a flex container. Each is ignored |
| `midi_clip_silent` | warning | A midi clip with no notes, or none starting inside its window |
| `midi_tempo_missing` | warning | The document has midi but no `tempo`, so notes read at the default BPM |
| `text_backing_unproven` | warning | Text over picture with no scrim, plate or outline, so nothing decides whether it is readable |
| `parent_missing`, `parent_not_group` | warning | A `parentId` naming nothing, or naming a clip that is not a group; the child renders unparented |
| `layer_cap_exceeded` | warning | More video clips overlap at an instant than the compositor draws |
| `animation_exceeds_clip` | warning | The window does not fit the clip after its delay, so the motion is clamped — or never runs |
| `stagger_compressed` | warning | The stagger span did not fit, so the per-unit offset was shrunk and the units overlap more than authored. At 0ms every unit moves in sync |
| `effect_animate_ignored` | warning | `animate` on a generator or stylize mode whose shader never reads the clock, so it stays still, or on a stylize `gradientWipe`, whose map it switches |
| `effect_washes_out` | warning | A `color` effect's `brightness` (an offset, not a multiplier) drives the picture to white or black, or its `contrast` removes nearly all of it |
| `animation_link_overrides` | warning | A link sets its channel outright and discards the clip's own animation there, or a wiggle discards the clip's placement |
| `typewriter_not_staggered` | warning | A `typewriter` is stored with a plain `durationMs` and no `stagger`, so the whole text appears at once after that time, with no caret |
| `animation_holds_rest_before_window` | warning | A custom `out` curve starts away from the channel's rest value after clip start, so the frames before its window show the rest value and then jump |
| `replace_curves_overlap` | warning | Two animations drive one absolute channel (`positionX/Y`, `anchorX/Y`, `trimStart/End`) at the same time; the last in document order wins and the other is discarded |
| `text_illegible` | warning | Type under 2.5% of frame height, or under a 3:1 contrast ratio against its own background plate or a full-frame shape clip behind it |
| `clips_overlap`, `clip_shorter_than_frame`, `caption_out_of_range`, `binding_incomplete`, `duplicate_track_index`, `transcript_clip_missing`, `link_partner_missing` | warning | Structural smells that still render |

`text_illegible` refuses to guess: a colour notation it cannot read, a
translucent plate, gradient-filled type, or a backdrop it cannot prove sits
behind the text ends the contrast check rather than producing a finding.

#### `nodetool timeline debug <timeline_id_or_file>`

Run the same check, then execute each scripted step against the headless
`ui_timeline_*` bridge and validate the document the session left behind. A
step names a tool with or without the `ui_timeline_` prefix. A failing step is
recorded and the script continues, so one bad target does not hide everything
after it. Rendering, playback, decode, and generation are not simulated; the
report lists that under `notSimulated`.

**Options:**

- `--interact <json>` — the scripted steps, e.g. `[{"tool":"add_track","input":{"type":"audio"}}]`.
- `--out <dir>` — bundle output directory (default `nodetool-debug/timeline-<id>-<timestamp>`).
- `--json` — print the full `TimelineDebugReport` as JSON to stdout.

```bash
nodetool timeline debug sequence.json \
  --interact '[{"tool":"add_track","input":{"type":"audio","name":"Music"}}]'
```

```
✓ ui_timeline_add_track

✅ Timeline is sound — 1 interaction(s) ran clean.
  timeline: 2 track(s), 1 clip(s), 4000ms @ 30fps
  session:  1 step(s), 0 failed
```

The bundle holds `report.json`, `report.md`, and `timeline.json` (the input
document). The command exits `0` only when the verdict is ok.

#### `nodetool timeline render <timeline_id_or_file>`

Render a timeline through the GPU compositor that the Render Timeline
node uses. The target is a timeline JSON file (a bare document, or a wrapper
with `document`, `fps`, `width`, `height` and `durationMs`, such as an example
bundle) or a `timeline_sequences` row id. Clip media resolves through the
local asset store. Video exports mix audio-track clips, MIDI clips, and embedded
video audio. Stills, contact sheets, PNG sequences, and video frame selections
carry no audio.

Use `--stills` to check a render frame by frame. The Canvas 2D frame preview
is a second renderer, so only this command shows the exported pixels.

**Options:**

- `--out <path>` — output file, or an output directory with loose `--stills` or `--sheet` (default `nodetool-render/<name>.<ext>`, `<name>-frames/`, or `<name>-sheet.png`).
- `--format <format>` — `mp4` (default), `webm`, `mov` or `png_sequence`.
- `--frames <spec>` — render only these frames: indices and inclusive ranges, e.g. `0-89` or `175,290,380-390`. Use `coverage:<file>` to read the `Frame` column of a Markdown table in a builder file's leading comment. Non-integer rows are skipped and reported.
- `--stills` — write each selected frame as `frame_<index>.png` instead of encoding a video.
- `--sheet [columns]` — write one PNG contact sheet instead of loose stills. It labels each frame and defaults to three columns. Select at most 60 frames. `--sheet` implies still-image output.
- `--only <clips>` — render clips selected by comma-separated names or ids. Selected groups include their children; other selections retain their ancestor groups and matte sources.
- `--scale <n>` — render at this fraction of the sequence size. The layout is the same as at full size.
- `--bitrate <bps>` — video bitrate in bits per second.
- `--json` — print the result as JSON.

```bash
nodetool timeline render sequence.json --out ad.mp4 --bitrate 12000000
nodetool timeline render <timeline_id> --stills --frames 175,290,376-384 --scale 0.5
nodetool timeline render sequence.json --frames coverage:build-sequence.ts --sheet --out review.png
nodetool timeline render sequence.json --only title,logo --frames 60,120 --sheet 2
```

The command needs ffmpeg for video output and a WebGPU adapter. On a headless
machine, install a Vulkan ICD such as lavapipe
([headless WebGPU setup](dev-environment.md#webgpu-on-a-headless-machine)).
The first frame compiles the GPU pipelines, so progress starts after a delay.

#### `nodetool timeline score <timeline_id_or_file>`

Score a timeline document's craft against the shipped showcase examples
(`cadence`, `kite`, `prism`, `serein`, `tidewater`, `voltra` —
`packages/base-nodes/nodetool/examples/timelines/*.timeline.json`,
excluding the footage-based `t-minus-30`) — a deterministic, no-LLM
scorecard, not a judge call. It measures motion density (animations and
keyframes per visible second), breadth (distinct animated properties, effect
types, showcase-only feature flags), and structural craft (style tracks, text
animators, authored transitions, scene count), each against the reference
examples' median, plus a fixed penalty per *distinct* showcase-tier warning
code present (the `showcase_*` codes that the `showcase` validation tier adds, which `timeline validate` does not run) — four findings of the
same code cost as much as one, so no single noisy check can dominate the
score; instance counts stay visible in `showcaseWarnings` and
`showcaseWarningCodeCounts`. The target is a timeline JSON file or
a `timeline_sequences` row id, same resolution as `timeline validate`.

**Options:**

- `--json` — print the full score result (every metric, its reference median and credit, the reference band, and the underlying validation) as JSON.
- `--min-score <n>` — exit non-zero when the score is below this threshold. Without it the command always exits 0 — the score is information, not a gate.
- `--sheet <out.png>` — also render a GPU contact sheet of 12 evenly spaced frames, through the same renderer `timeline render --sheet` uses.

```bash
nodetool timeline score <timeline_id>
nodetool timeline score sequence.json --json
nodetool timeline score <timeline_id> --min-score 70
nodetool timeline score <timeline_id> --sheet review-sheet.png
```

See [Harness Reference](harnesses.md#nodetool-timeline-score-timeline-craft-scorecard)
for the score formula and `scripts/motion-craft-eval.mjs`, the eval loop that
runs an agent brief and scores what it produced.

#### `nodetool timeline versions`

Read and write a sequence's snapshot history against the local database:
manual saves, the autosaves `timeline.update` writes at most every five
minutes, and the pre-restore snapshot that makes a restore undoable.

**Subcommands:** `list`, `show`, `create`, `restore`, `delete`. All five take `--json`.

- `list <timeline_id>` — newest first; `--save-type <manual|autosave|restore>`, `--limit <n>` (default 100).
- `show <timeline_id> <version>` — one version's metadata and the document it stored.
- `create <timeline_id>` — snapshot the sequence as it stands now; `--name <name>` labels it.
- `restore <timeline_id> <version>` — snapshot the current state first, write the old document back, then re-validate it.
- `delete <timeline_id> <version>` — `-y, --yes` skips the confirmation prompt.

```bash
nodetool timeline versions list <timeline_id> --save-type manual --limit 10
nodetool timeline versions show <timeline_id> 3 --json
nodetool timeline versions create <timeline_id> --name "before the recut"
nodetool timeline versions restore <timeline_id> 3
nodetool timeline versions delete <timeline_id> 3 --yes
```

An old document is restored against today's schema, so what it used to pass is
not what it passes now: a restore whose document no longer validates prints the
issues and exits non-zero.

### `nodetool sketch`

**Subcommands:** `validate`, `debug`, `versions`

#### `nodetool sketch validate <sketch_id_or_file>`

Check a sketch (image document) without opening an editor: a duplicate layer
id, an `activeLayerId` or binding pointing at a layer the document lacks,
opacity or a blend mode no compositor ships, a binding with no workflow or
prompt behind it, and fields a schema round trip would strip. The target is a
`{sketch, layerBindings}` JSON file — or anything carrying one, so a
`sketch.get` response works as-is — or an `image_documents` row id. Layer
bitmaps stay opaque to the check.

**Options:**

- `--json` — print the full `SketchValidation` as JSON.
- `--warnings-as-errors` — exit non-zero on warnings, not just errors.

```bash
nodetool sketch validate sketch.json
nodetool sketch validate <image_document_id> --json
```

#### `nodetool sketch debug <sketch_id_or_file>`

Run the same check, then execute each scripted step against the headless
`ui_sketch_*` bridge (the prefix is optional) and validate the document the
session left behind. Pixels, painting, rendering, generation, and asset I/O are
not simulated; the report lists that under `notSimulated`.

**Options:**

- `--interact <json>` — the scripted steps, e.g. `[{"tool":"add_layer","input":{"name":"Glow"}}]`.
- `--out <dir>` — bundle output directory (default `nodetool-debug/sketch-<id>-<timestamp>`).
- `--json` — print the full `SketchDebugReport` as JSON to stdout.

```bash
nodetool sketch debug sketch.json \
  --interact '[{"tool":"add_layer","input":{"name":"Shadow"}},
               {"tool":"set_layer_props","input":{"target":"Shadow","opacity":0.4,"blendMode":"multiply"}}]'
```

```
✓ ui_sketch_add_layer
✓ ui_sketch_set_layer_props

✅ Sketch is sound — 2 interaction(s) ran clean.
  sketch:  2 layer(s), 0 binding(s), 1024x768
  session: 2 step(s), 0 failed
```

The bundle holds `report.json`, `report.md`, and `sketch.json`.

#### `nodetool sketch versions`

The same five subcommands as `timeline versions`, against an image document's
history. The per-layer generation takes in the editor are a different thing:
those record one generated image on one layer, these snapshot the whole
document.

```bash
nodetool sketch versions list <image_document_id> --save-type manual --limit 10
nodetool sketch versions show <image_document_id> 3 --json
nodetool sketch versions create <image_document_id> --name "before the repaint"
nodetool sketch versions restore <image_document_id> 3
nodetool sketch versions delete <image_document_id> 3 --yes
```

### `nodetool jsscript`

A JS script is a named, versioned script document: a body plus declared ports,
secrets, a timeout, and saved test cases. Commands that take a script target
accept a script JSON file (a bare `JsScriptDocument` or anything carrying one
under `document`) or a `js_scripts` row id.

**Subcommands:** `schema`, `example`, `validate`, `run`, `test`, `debug`, `versions`

#### `nodetool jsscript schema` and `example`

Print the document JSON Schema or a runnable document with a saved test. Both
write JSON to stdout and need no database or provider credentials.

```bash
nodetool jsscript schema
nodetool jsscript example > script.json
nodetool jsscript validate script.json --warnings-as-errors
nodetool jsscript test script.json
```

Saved cases use `expect` for final output values and `expectedStreamed` for
ordered `{name, value}` emissions. Unknown case fields, including `expected`,
are errors so a misspelled assertion cannot pass without being graded.

#### `nodetool jsscript validate <script_id_or_file>`

Check the body's syntax, its imports against the installed pack catalog,
undefined names, undeclared `inputs.*` reads, outputs no `emit`/`output` call
reaches, duplicate or non-identifier port names, and tests naming ports the
script does not declare. A body that declares outputs and returns them instead
of emitting them is an error — a script has no legacy return contract. Zero
saved tests, and a declared secret this install lacks, are warnings.

**Options:**

- `--json` — print the full `JsScriptValidation` as JSON.
- `--warnings-as-errors` — exit non-zero on warnings, not just errors.

```bash
nodetool jsscript validate script.json
nodetool jsscript validate <js_script_id> --warnings-as-errors
```

#### `nodetool jsscript run <script_id_or_file>`

Validate the document, then execute the body once in the QuickJS sandbox and
print its outputs, streamed emits, logs, and error. Declared outputs must use
`await output(name, value)` or `await emit(name, value)`. A body that reads its
inputs with `stream` is fed with `--input-streams` instead of `--inputs`. A
staged handle the script does not declare is refused.

**Options:**

- `--inputs <json>` — input values, e.g. `'{"a":1}'`.
- `--input-streams <json>` — items staged per handle, e.g. `'{"nums":[1,2,3]}'`.
- `--json` — print the run result as JSON.

```bash
nodetool jsscript run script.json --inputs '{"numbers":[1,2,3]}'
nodetool jsscript run script.json --input-streams '{"numbers":[1,2,3]}'
```

```
✅ ran in 1032ms
  outputs:  {"total":6}
  streamed: [{"name":"running","value":1},{"name":"running","value":3},{"name":"running","value":6}]
```

#### `nodetool jsscript test <script_id_or_file>`

Validate the document, then run its saved test cases and grade each one. Exit
non-zero when validation or any case fails.

**Options:**

- `--json` — print the grade report as JSON.

```bash
nodetool jsscript test script.json
nodetool jsscript test <js_script_id> --json
```

```
✅ 2 passed, 0 failed
  ✓ sums three numbers
  ✓ sums an empty list to zero
```

#### `nodetool jsscript debug <script_id_or_file>`

Replay each scripted step against the headless `ui_jsscript_*` bridge (the
prefix is optional) and validate the document the session left behind. A failed
run or test result fails the interaction, report verdict, and command exit status.
The editor, persistence of a debug session, and secret values are not simulated.

**Options:**

- `--interact <json>` — the scripted steps, e.g. `[{"tool":"set_code","input":{"code":"..."}}]`.
- `--out <dir>` — bundle output directory (default `nodetool-debug/jsscript-<id>-<timestamp>`).
- `--json` — print the full `JsScriptDebugReport` as JSON to stdout.

```bash
nodetool jsscript debug script.json \
  --interact '[{"tool":"set_code","input":{"code":"await output(\"n\", 1);"}}]'
```

#### `nodetool jsscript versions`

The same five subcommands as `timeline versions`, against a script's history.

```bash
nodetool jsscript versions list <js_script_id> --limit 10
nodetool jsscript versions create <js_script_id> --name "before the rewrite"
nodetool jsscript versions restore <js_script_id> 3
```

### `nodetool game`

Validate, replay, capture, and export a native game document from a JSON file.
Simulation advances exactly the requested number of 60 Hz ticks. The input
recording is a JSON array of `{ "pressed": ["right"] }` frames, one per tick.

```bash
nodetool game validate game.json
nodetool game simulate game.json --ticks 120 --inputs inputs.json --expect-score 1 --expect-win
nodetool game simulate game.json --ticks 120 --inputs inputs.json --assertions assertions.json --verify-replay
nodetool game capture game.json --ticks 120 --inputs inputs.json --out frame.png
nodetool game capture game.json --ticks 120 --out frame.png --backend webgpu
nodetool game build game.json --out game-build --assets-dir game-assets
nodetool game build game.json --out game-desktop --assets-dir game-assets --target tauri --name "Cave Run" --compile
nodetool game smoke game-build --json
```

**Subcommands and options:**

- `game validate <game_file>` — `--json`.
- `game simulate <game_file>` — `--ticks <count>` (required), `--seed <integer>` (default `1`), `--inputs <file>`, `--assets-dir <directory>` (prepared 3D collider files named `<full-asset-id>.json`), `--expect-score <score>`, `--expect-win`, `--assertions <file>`, `--verify-replay`, `--saves <file>`, `--json`.
- `game capture <game_file>` — `--ticks <count>` and `--out <file>` (both required), `--seed <integer>`, `--inputs <file>`, `--scale <factor>` (default `1`), `--backend <canvas2d|webgpu|webgl2>`, `--assets-dir <directory>`, `--json`. A schema version 3 (3D) document defaults to `webgl2`. A 2D document accepts `canvas2d` (default) or `webgpu`.
- `game build <game_file>` — `--out <directory>` (required), `--assets-dir <directory>`, `--target <web|tauri>` (default `web`), `--json`. `--target tauri` writes a [Tauri](https://tauri.app) desktop app project: the web player in `dist/` and a native shell in `src-tauri/`. It accepts `--name <name>`, `--identifier <reverse-dns>`, `--app-version <MAJOR.MINOR.PATCH>`, `--icon <png|jpeg|webp>`, and `--compile`, which runs `cargo build --release` and reports the executable path. Run `npm install && npm run build` in the project for installers.
- `game smoke <directory>` — `--json`. Serves an exported build in headless Chromium with outside network access blocked, renders 300 scripted frames, and checks tick progress. Console errors, page errors, failed requests, and stalled ticks return a nonzero exit status. Install Chromium with `npx playwright install chromium`.

Schema version 2 adds sprite `visualAnimation` tracks and scene `backgrounds`.
Tracks use scene or spawn age in ticks and animate rotation, scale, opacity, and
tint. Backgrounds use per-axis parallax (`0` screen-fixed, `1` world-fixed),
scroll rate in world units per second, and `none`, `repeat`, or `mirror` tiling.
`game capture` derives both at the selected tick, so a resumed simulation and
an arbitrary-tick capture use the same positions.
Scene `lighting` adds ambient color and up to 32 colored point lights. Each
point has world position, radius, intensity, and falloff. Set `sprite.unlit`
for emissive art. Lighting applies to the world before effects and leaves HUD
text unchanged.

`game capture` uses Canvas2D by default. It reports omitted optional GPU
effects and fails if an effect is required. Choose `--backend webgpu` to run
bloom, brightness/contrast, and LUT grading through the GPU modules used by
the browser. The command fails when a required effect cannot run. The JSON
report includes effect diagnostics. GPU capture requires the local Dawn WebGPU
adapter.

A 3D capture checks the frame against the document's `performance.budgets`,
with player defaults for any budget left out. The JSON report has
`budget.limits` and `budget.overruns`, and `stats.culledEntities` counts the
entities that distance culling hid. Text mode prints each overrun to stderr.

`--assertions` checks selected ticks. Tick 0 is the initial state; tick 1 is
after the first input frame. Each listed tick may check `sceneId`, entity
`x`/`y`/`active`, and the complete ordered `events` array for that tick. Omitted
fields are not checked. A missing entity fails the assertion. The default
position tolerance is `1e-9`; set `tolerance` at the top level to change it.
Assertions beyond `--ticks` and duplicate tick entries are errors.

```json
{
  "tolerance": 0.000001,
  "ticks": [
    {
      "tick": 0,
      "sceneId": "room",
      "entities": [{ "id": "player", "x": 0, "y": 0, "active": true }],
      "events": []
    },
    { "tick": 1, "events": [] }
  ]
}
```

`--verify-replay` saves the midpoint state, resumes it in a second session,
and compares snapshots and ordered events after each remaining tick. The JSON
report includes `replay.resumeTick`, `replay.verified`, and the first divergent
tick and path when it fails. This check excludes script execution timing.

Script `save` and `load` commands use in-memory storage during `simulate`.
`--saves <file>` seeds it from `{progress?, settings?}`, each mapping a slot name
to its data object. A load result reaches scripts on the next tick's input frame,
and the replay session steps the same frame, so `--verify-replay` covers loads.
When a script saved or loaded, the JSON report lists each slot's data under
`saves` with any storage errors.

`--assets-dir` supplies media files named `<full-asset-id>.<extension>` for
capture and export. The build copies media into a content-addressed folder and
includes a standalone browser player. Built-in sample art needs no media files.

Schema version 2 permits up to eight ordered `renderEffects`. `brightnessContrast`
sets `brightness` from `-1` to `1` and `contrast` from `0` to `4`.
`bloom` sets `threshold`, `softness`, `radius` in pixels, and `intensity`.
`lut` references an opaque image asset laid out as a packed 2D cube with
dimensions `size² × size`. `hudEffectOrder` chooses `beforeEffects` or
`afterEffects`; bloom defaults to HUD after effects, while a legacy one-effect
document keeps its earlier output. Each effect has `required`: a missing GPU
capability fails for required effects and reports omitted optional effects.
Canvas capture reports the same omission policy; GPU capture applies the chain.
HUD labels may refer to an embedded font asset by `fontId`. Capture loads the
font bytes before drawing; a missing required font fails and an optional font
reports its fallback. To compare browser and headless text metrics, run
`NODETOOL_BROWSER_FONT_TEST=1 npm run test --workspace=packages/game-renderer -- fonts.test.ts`.

Schema version 2 scenes can set `music: {"assetId":"music","volume":0.5,
"fadeInTicks":30,"fadeOutTicks":30}` to loop an installed audio asset. Audio
sources on entities remain one-shot effects. Their `volume` defaults to `1`,
and triggered effects can overlap up to the browser player's 32-voice cap.
Music position is saved as a scene start tick; loading seeks within the decoded
loop. Audio starts after a browser gesture. The CLI simulation and image capture
record logical audio events and state without producing audio samples.

## Project Files

### `nodetool projects`

List projects and move whole projects between installs as `.nodetool-project`
files. Runs against the local database, asset store, and workspaces. The
server serves the same format at `GET /api/projects/:id/export` and
`POST /api/projects/import`, so a file from either side imports on the other.

**Subcommands:**

- `list [--archived] [--json]` — projects, newest first.
- `export <project_id> [-o <file>] [--json]` — write the project to a file.
  The default name is `<project name>.nodetool-project`.
- `import <file> [--name <name>] [--json]` — create a new project from a file
  and print its id.

A project file holds every row filed under the project: workflows and their
versions, storyboards, scripts, timelines, sketches, mini apps, JavaScript
scripts, and games, each with its version history. It also holds chats with
their messages, assets with their stored bytes, and every file in the
project's workspaces. Jobs, run traces, and spend records stay behind.

Import never overwrites. It creates a new project and gives every row a new
id, then rewrites each reference to an exported id: id columns, document JSON,
`asset://` locators, workspace file paths, and JSON files in workspaces.
Imported workflows are private. Workspaces become managed folders, because the
exporting machine's paths mean nothing on another one. The Personal project
imports as an ordinary named project.

**Examples:**

```bash
nodetool projects list
nodetool projects export 6f1c2e... -o launch-spot.nodetool-project
nodetool projects import launch-spot.nodetool-project --name "Launch spot (copy)"
```

## Job Management

### `nodetool jobs`

Query job status and results. Reads the local database by default.

**Subcommands:** `list`, `get`

**Options:**

- `--api-url <url>` — query a remote server instead of the local database.
- `--workflow-id <id>` — filter by workflow ID (for `list`).
- `--limit <n>` — max results (default: `100`).
- `--json` — output as JSON.

`--json` returns the same declared row shape whether the command read the local
database or a server. The local read used to dump every column, including a
job's whole `graph` and `logs`.

**Examples:**

```bash
# List all jobs
nodetool jobs list

# Filter by workflow
nodetool jobs list --workflow-id workflow_abc123

# Get a specific job
nodetool jobs get <job_id>
```

### `nodetool runs`

Inspect durable app runs, workflow jobs and chat turns. Reads the local
database as the local account by default. `--api-url <url>` or
`NODETOOL_API_URL` selects a server. Every query stays within the caller's account.
Local reads apply pending SQLite migrations before opening the database.

Run IDs accept their full value or an exact 12-character resource prefix.
Ambiguous prefixes fail. Trace and span IDs always retain their full OTel
values. `show`, `trace` and `logs` also accept a workflow job or chat message
ID when it identifies a recorded run.

| Command | Output and filters |
|---|---|
| `runs list` | Newest runs. `--kind app\|workflow\|chat`, `--app-id`, `--instance-id`, `--workflow-id`, `--thread-id`, `--status running\|completed\|failed\|cancelled`, `--origin ui\|agent\|cli\|debug\|public`, `--since <ISO time>`, `--until <ISO time>`, `--limit` (default 20, maximum 100), `--cursor` |
| `runs show <id>` | Record and bounded summary: failed span and root path, provider costs, slow spans, span counts, generations, documents and content flags. `--include-content` adds capped app run inputs and outputs |
| `runs trace <id>` | Span tree. The table has a `detail` column: `<node id> (<node type>)` on node spans, `<provider>/<model>` on LLM spans. `--depth` (default 4, maximum 64), `--focus <full span ID>`, `--name`, `--errors-only`, `--limit` (default 100, maximum 500) |
| `runs logs <id>` | Events, oldest first. With `--include-content` the table adds `message` and `arguments` columns. Without it, the table ends with a hint that messages are excluded. `--level`, `--source`, `--span <full span ID>`, `--since-ms`, `--until-ms`, `--limit` (default 100, maximum 500), `--cursor` |
| `runs tail <id>` | Follow until terminal. `--cursor` (default 0), `--limit` (default 100, maximum 500), `--poll-interval <ms>` (default 250, range 10–5000), `--timeout <seconds>` |

Each command accepts `--json`. Single reads return the shared runs service's
JSON result. Lists and logs return an opaque `next_cursor` for the next page.
Content is excluded by default. `show --include-content` adds the app record's
capped inputs and outputs. `trace --include-content` requires `--focus`.
`logs --include-content` and `tail --include-content` return capped owner
content. Results report expired, absent, visitor or suppressed content and
trace truncation or incompleteness.

`tail --json` emits NDJSON, one object per line. Its frame types are
`resnapshot`, `span`, `event`, `reconnecting`, `terminal`, `interrupted`,
`gap` and `error`. The store retains each span's latest complete snapshot, so a
`resnapshot` frame warns that intermediate updates may have been coalesced.
Replace cached spans by their full snapshot. Event frames are deduplicated by
span ID and stable event ID during the process. After a connection loss, tail
retries from its durable cursor. Save the final `cursor` to resume in another
process. A terminal run with all spans ended drains its remaining snapshots and
returns immediately. If final spans are still arriving, tail waits until they
end or until one second passes without a new cursor, with a five-second maximum
drain. An unfinished trace produces a `gap` frame and a terminal frame with
`trace_settled: false`. Reader limits appear on `resnapshot` frames. Deduplication
memory is bounded by the store's span and event caps. A `dedup_limit` gap warns
if later records exceed those bounds.
Ctrl-C stops following, removes the signal handler and exits 130. It does not
cancel the run. A timeout or read error exits 1. Following a failed run to its
terminal record exits 0.

```bash
nodetool runs list --kind app --status failed --json
nodetool runs show <run_id> --json
nodetool runs trace <run_id> --focus <span_id> --include-content --json
nodetool runs logs <run_id> --level error --include-content --json
nodetool runs tail <run_id> --cursor 42 --json
```

## Asset Management

### `nodetool assets`

Manage uploaded files and workflow assets. Reads the local database by default.

**Subcommands:** `list`, `get`

**Options:**

- `--api-url <url>` — query a remote server instead of the local database.
- `--query <q>` — search query (for `list`).
- `--content-type <type>` — filter by content type (for `list`).
- `--limit <n>` — max results (default: `100`).
- `--json` — output as JSON.

**Examples:**

```bash
# List assets
nodetool assets list

# Search assets
nodetool assets list --query "landscape"

# Get a specific asset
nodetool assets get <asset_id>
```

## Storage Maintenance

### `nodetool storage migrate-keys`

Move asset objects under their owner's prefix, so an object's key is
`<userId>/<assetId>.<ext>` and the owner is the leading path segment — the
boundary a Supabase RLS policy or an S3 bucket policy can enforce on the object
itself.

Run it once when upgrading an S3 or Supabase install that still holds objects
written under the older flat layout. The local file backend needs no migration:
it falls back to the flat key on a miss.

**Options:**

- `--dry-run` — report what would move without writing anything.
- `--user-id <id>` — migrate only this user's objects.
- `--json` — output the report as JSON.

**Examples:**

```bash
# See what would move first
nodetool storage migrate-keys --dry-run

# Move them
nodetool storage migrate-keys

# One user, machine-readable
nodetool storage migrate-keys --user-id 1 --json
```

A dry run reports the tally and writes nothing:

```
scanned 0, would move 0, already migrated 0, absent 0, failed 0
Dry run — nothing was written. Re-run without --dry-run.
```

## Vector Collections

### `nodetool collections`

Manage the RAG vector-store collections behind semantic search. Runs in-process
against the default vector provider — sqlite-vec unless
`NODETOOL_VECTOR_PROVIDER` points elsewhere — so no server has to be running.

**Subcommands:** `list`, `get`, `create`, `delete`, `query`, `index`

**Options:**

- `--embedding-model <model>` / `--embedding-provider <provider>` — record the
  embedding model and provider on a new collection (for `create`).
- `-n, --n-results <n>` — number of matches to return (for `query`, default `10`).
- `-y, --yes` — skip the confirmation prompt (for `delete`).
- `--json` — output as JSON. Available on every subcommand.

**Examples:**

```bash
# Create a collection and record what embeds it
nodetool collections create my_docs --embedding-model text-embedding-3-small

# Chunk and index files, with the same splitter the server uses
nodetool collections index my_docs notes.md report.txt

# Semantic search
nodetool collections query my_docs "how does the runner cancel a job" -n 5

# Inspect and remove
nodetool collections list
nodetool collections get my_docs
nodetool collections delete my_docs --yes
```

Re-indexing the same file replaces its chunks rather than adding a second copy,
so an `index` run is safe to repeat after a document changes.

## Spend Tracking

### `nodetool costs`

Report what NodeTool has spent on LLM and provider calls. Every call writes a
row carrying its cost and token counts; these subcommands aggregate them
straight from the local database, so no server has to be running.

Reach for it to answer "what did that run cost" after the fact, or to find
which model is eating a budget.

**Subcommands:** `summary`, `list`, `by-provider`, `by-model`

**Options:**

- `--provider <name>` — filter by provider (for `list` and `by-model`).
- `--model <id>` — filter by model (for `list`).
- `--limit <n>` — max results (for `list`, default `50`).
- `--json` — output as JSON. Available on every subcommand.

**Examples:**

```bash
# Overall spend plus per-provider and per-model breakdowns
nodetool costs summary

# Recent calls, most recent first
nodetool costs list --limit 20
nodetool costs list --provider anthropic

# Grouped totals
nodetool costs by-provider
nodetool costs by-model --provider openai
```

`summary` prints the overall total and both breakdowns:

```
Overall
 key          │ value
──────────────┼─────────
 total_cost   │ $0.0000
 total_tokens │ 0
 calls        │ 0

By provider
(no results)

By model
(no results)
```

Supervised runs and `nodetool app build` write to the same ledger, tagged
`supervisor` and `app-build` in `node_type`, so `costs list` shows what the
harnesses spent alongside the workflow's own calls.

### `nodetool generations`

Inspect the record of every media generation — image, video, audio, 3D. One
row per provider call, opened before the call as `running` and closed with its
outcome (`completed`, `failed`, `cancelled`, or `interrupted` when a restart
orphaned it), its cost, the provider's request id, and the asset ids it
produced. Like `costs`, it reads the local database, so no server has to be
running.

Where `costs` aggregates LLM spend, this answers the per-call questions: what a
render cost, which asset it wrote, and whether it is still going.

**Subcommands:** `list`, `get`, `await`, `cancel`, `reconcile`, `sweep`, `provider-list`, `provider-get`

**Options:**

- `--status <status>` — `pending`, `running`, `recovering`, `completed`, `failed`,
  `cancelled`, `needs_attention`, or `interrupted` (for `list`).
- `--provider <name>` / `--capability <name>` — filter by provider, or by
  capability such as `text_to_video` (for `list`).
- `--thread-id <id>` / `--job-id <id>` — only what a chat thread or a workflow
  run asked for (for `list`).
- `--since <iso>` — only generations created at or after this time (for `list`).
- `--limit <n>` — max results (for `list`, default `50`).
- `--timeout <seconds>` — how long `await` waits before giving up (default `300`).
- `--provider <name>` is required, with `--model <id>`, for `provider-list` and `provider-get` (see below).
- `--json` — output as JSON. Available on every subcommand.

**Examples:**

```bash
# What ran, newest first
nodetool generations list
nodetool generations list --status running
nodetool generations list --provider fal --capability text_to_video
nodetool generations list --since 2026-09-01T00:00:00Z --limit 20

# One row in full — cost, assets, origin, reconcile state
nodetool generations get <generation_id> --json

# Block until it settles, then act on the exit code
nodetool generations await <generation_id> --timeout 600

# Close a running row, or ask the provider what it actually billed
nodetool generations cancel <generation_id>
nodetool generations reconcile <generation_id>

# Close orphaned rows and drain the reconcile queue once
nodetool generations sweep
```

With nothing recorded, `list` prints `No generations recorded.`

**Exit codes are the verdict**, so these compose in a script:

- `await` — `0` once the row reaches a terminal status, `1` if it is still
  running when the timeout expires.
- `cancel` — `0` if it was running and is now cancelled, `1` if it had already
  settled or does not exist. The provider call itself finishes on its own in
  whatever server process started it; only the record is closed.
- `reconcile` — `0` when the provider answered and the row was updated, `1`
  otherwise, with a `reason` naming which: `no request id`, or
  `no reconciler for <provider>` when that provider has no billing API, which
  also marks the row `unavailable` so it leaves the queue.

`get` prints the whole record: `status`, `provider`, `model`, `capability`,
`cost` with its `currency`, `billing_unit`, `quantity` and `unit_price`,
`asset_ids`, `started_at`/`completed_at`/`duration_seconds`, an `origin` block
(`surface`, `thread_id`, `tool_call_id`, `job_id`, `node_id`, `workflow_id`),
the `provider_request_id`, and a `reconcile` block (`reconciled_at`,
`attempts`, `error`, `next_at`).

A model in no price catalog still gets a row, with `cost` null — `list` shows
it as `unpriced` rather than as free.

`sweep` is the startup sweep by hand: it closes rows still marked `running` as
`interrupted`, then drains the reconcile queue once. The server runs it at
every start, so reach for it when working against the database directly. Rows
awaiting reconciliation retry with backoff at 1, 5, 30, 120, and 720 minutes.

`provider-list` and `provider-get <request_id>` ask the provider instead of the
local database: its own record of what this account ran, from any machine, at
the price it billed. Both require `--provider <name>` (for example `fal_ai`) and
accept `--model <id>`, the endpoint id (comma-separated for `provider-list`).
`provider-list` also takes `--status <running|completed|failed|cancelled|unknown>`,
`--since <iso>`, `--until <iso>` (exclusive), `--limit <n>` (default `50`) and
`--cursor <cursor>`, the `next_cursor` that a previous page printed. A provider
without a generations API reports that instead of an empty list.

```bash
nodetool generations provider-list --provider fal_ai --status running
nodetool generations provider-get <request_id> --provider fal_ai --model fal-ai/flux/dev
```

Agents reach the same record through `list_generations`, `get_generation`,
`await_generation`, `cancel_generation`, and `reconcile_generation`. Design:
[Media generation tracking](media-generation-tracking-design.md).

## Secrets Management

### `nodetool secrets`

Manage encrypted secrets stored in the local database with per-user encryption.

**Subcommands:** `list`, `store`, `get`

**Examples:**

```bash
# List stored secret keys
nodetool secrets list

# Store a secret (prompts for value)
nodetool secrets store OPENAI_API_KEY

# Retrieve a secret value
nodetool secrets get OPENAI_API_KEY
```

## Provider Sign-In

### `nodetool auth`

Sign in to providers that use an account instead of an API key. Today that is
Claude: `auth claude` runs the same OAuth flow the `claude` CLI does and writes
the tokens to the Claude Agent SDK's credential file
(`~/.claude/.credentials.json`), so a NodeTool login and a `claude login` are
interchangeable. The Claude Agent provider picks the file up with no further
configuration. No database and no server are involved.

Reach for it when you want to run agents on a Claude subscription rather than
store an `ANTHROPIC_API_KEY`.

**Subcommands:** `claude login`, `claude status`, `claude refresh`, `claude logout`

**Options:**

- `--console` — sign in with a Console (API-billed) account instead of a
  subscription (for `login`).
- `--manual` — skip the loopback listener and paste the code the browser shows.
  This is the flow for a headless or remote machine (for `login`).
- `--no-browser` — print the URL instead of opening a browser (for `login`).
- `--force` — refresh even when the current token is still valid (for `refresh`).
- `--json` — output as JSON. Available on `login`, `status`, and `refresh`.

**Examples:**

```bash
# Browser opens, loopback callback completes the flow
nodetool auth claude login

# Headless box: paste the code yourself
nodetool auth claude login --manual

# Check and refresh
nodetool auth claude status
nodetool auth claude refresh --force

nodetool auth claude logout
```

The same flow is available over HTTP at
`/api/oauth/claude/{start,complete,tokens,disconnect}` and as a sign-in card on
the **Models & Providers** settings page.

## Settings

### `nodetool settings show`

Display the current values of a fixed list of environment variables: `ENV`, `LOG_LEVEL`, `PORT`, `HOST`, `DB_PATH`,
`NODETOOL_API_URL`, the provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `MISTRAL_API_KEY`,
`GROQ_API_KEY`, `SERPAPI_API_KEY`), `OLLAMA_API_URL`, `HF_TOKEN`, `VECTORSTORE_DB_PATH`, `ASSET_BUCKET`, and
`S3_ENDPOINT_URL`. A variable whose name ends in `KEY` or `TOKEN` prints as `***` when set. Unset variables print empty.

**Options:**

- `--json` — output as JSON.

**Example:**

```bash
nodetool settings show
nodetool settings show --json
```

## Model Management

### `nodetool models`

List models and providers. Queries local providers and caches by default. `providers`, `list` and `by-provider mlx` start the local Python worker for MLX when the Python environment exists, and stop it when the command ends. Without that environment the other providers still list. `--api-url <url>` (default `NODETOOL_API_URL`) targets a remote server on `list`, `ollama`, and `huggingface`. Every subcommand takes `--json`.

**Subcommands:**

- `list` — list all models (recommended + provider + HuggingFace cached).
- `providers` — list configured providers and their capabilities.
- `recommended` — list recommended models.
- `ollama` — list Ollama models.
- `huggingface` — list HuggingFace cached models (`--query`, `--type` to filter).
- `by-provider <provider>` — list models for a provider; `--kind` one of `llm`, `image`, `tts`, `asr`, `video`, `embedding` (default `llm`).

**Examples:**

```bash
nodetool models list
nodetool models providers
nodetool models ollama
nodetool models by-provider openai --kind image
```

#### `nodetool models recommended`

The curated shortlist — what to reach for when you want a model id that is known
to work, rather than everything a provider will sell you. It reads the shipped
`RECOMMENDED_MODELS` table in-process, so it needs no server, no API key, and no
network.

- `--category <category>` — one of `all` (default), `image`,
  `image-text-to-image`, `image-image-to-image`, `language`,
  `language-text-generation`, `language-embedding`, `asr`, `tts`,
  `video-text-to-video`, `video-image-to-video`. An unknown value exits `1`
  listing the valid ones.
- `--system <darwin|linux|windows>` — keep only models that run on that
  platform. A model that declares no platforms is kept for all of them.
- `--limit <n>` — cap the results. Applied after both filters; a value that is
  not a positive integer exits `1`.
- `--check-servers` — fetch the list from a running server instead, which also
  probes whether the local Ollama and llama.cpp servers can serve each entry.
  This is the only mode that uses `--api-url` (default `http://localhost:7777`,
  or `NODETOOL_API_URL`).
- `--json` — the full model records rather than the five-column table.

```bash
# The top of the curated list, no network
nodetool models recommended --limit 3

# What to embed with
nodetool models recommended --category language-embedding

# What a Mac can actually run, machine-readable
nodetool models recommended --system darwin --json

# Which of them a local Ollama has pulled
nodetool models recommended --check-servers
```

### `nodetool models` — HuggingFace Hub and cache

Five subcommands search the Hub and manage the local HuggingFace cache. They
talk to the Hub's HTTP API and the cache on disk, so they need no running
NodeTool server. Reach for them when you want to find a repo id for a local
model node, or pull the weights before a workflow needs them.

- `hf-types` — print the nodetool HF model types, one per line, followed by a
  `Generic types (require --task):` block. The types in that block need a
  pipeline tag when you search them; the ones above it do not. `--json` prints
  `{types, generic}` instead.
- `list-hf <model_type>` — search the Hub for models matching a nodetool model
  type. `--task <task>` supplies the HF pipeline tag (required for the generic
  types); `--limit <n>` caps the results; `--json` prints the raw entries.
  Without `--task`, a generic type fails with
  `Model type 'hf.model' requires --task (e.g. 'text-to-image').`
- `list-hf-all` — search across every nodetool HF model type at once.
  `--limit <n>` caps the total; `--repo-only` drops file-level entries and keeps
  one row per repo.
- `hf-cache` — list the local cache with disk detail: repo id, path, type,
  whether the repo is fully downloaded, size on disk, pipeline tag.
  `--downloaded-only` keeps only complete repos; `--limit <n>` and `--json`
  behave as elsewhere. `huggingface` (above) reads the same cache but prints
  id/name/provider/type/repo_id and can search it (`--query`, `--type`) or
  target a remote server (`--api-url`); use `hf-cache` when you care about what
  is on disk and how big it is.
- `download-hf --repo-id <owner/name>` — download a repo into the local
  HuggingFace cache, printing progress to stderr. `--file-path <path>` fetches a
  single file instead of the whole repo; `-a, --allow-patterns <glob>` and
  `-i, --ignore-patterns <glob>` are repeatable globs that narrow the file set.
  Passing `--cache-dir <dir>` switches the download to the flat llama.cpp cache
  layout (`~/.cache/llama.cpp` on Linux, `~/Library/Caches/llama.cpp` on macOS)
  — it does **not** redirect the files into `<dir>`.

**Examples:**

```bash
# Which model types can be searched, and which need --task
nodetool models hf-types

# Search one type, then every type at once
nodetool models list-hf qwen3 --limit 5
nodetool models list-hf hf.text_to_image --task text-to-image --limit 5
nodetool models list-hf-all --repo-only --limit 20

# What is already on disk
nodetool models hf-cache --downloaded-only

# Pull weights ahead of a run — whole repo, then just the GGUF files
nodetool models download-hf --repo-id Qwen/Qwen3-0.6B
nodetool models download-hf --repo-id Qwen/Qwen3-0.6B-GGUF -a "*.gguf"
```

## Media Generation

### `nodetool generate <provider> <model> <prompt...>`

Local and self-hosted installs expose `fake-image` and `fake-video` on provider
`fake` for no-cost tests without credentials. They produce a fixed PNG or a
two-second MP4. Select fake explicitly with `find_model({provider_hint: "fake", ...})`.
Automatic model selection excludes fake media. `generate` accepts image models.
For a video fixture, use the video tool:

```bash
nodetool tools info generate_video
nodetool tools call generate_video --args '{"provider":"fake","model":"fake-video","prompt":"test fixture"}'
nodetool skills read storyboard-core
```

Storyboards can use these refs as `image_model` and `video_model` board defaults.

Generate an image from any registered provider straight to a file, with no
workflow in between. Use it to try a model, check that a provider's key resolves,
or produce one image from a script.

Provider and model names are matched leniently against the registry and the
provider's own model manifest, so `fal-ai` finds `fal_ai` and `flux-schnell`
finds `fal-ai/flux/schnell`. The API key comes from the secret store or the
environment; when it is missing the error names the variable to set.

**Options:**

- `-o, --output <path>` — output file or directory. A directory (or a path
  ending in a separator) keeps the generated name; a file path is used as the
  base name. Default is a `nodetool-<model>-<timestamp>.<ext>` file in the
  working directory.
- `--width <n>` / `--height <n>` / `--aspect-ratio <ratio>` — output size, e.g.
  `--aspect-ratio 16:9`.
- `--negative-prompt <text>` — what to avoid in the image.
- `--seed <n>` — random seed, for reproducible output.
- `-n, --num-images <n>` — number of images (default `1`). Extra images get a
  `-2`, `-3`, … suffix.
- `--steps <n>` / `--guidance <n>` — inference steps and guidance scale.
- `--image <path...>` — input image(s); switches to image-to-image.
- `--strength <n>` — image-to-image strength, with `--image`.
- `--list-models` — list the provider's image models and exit.
- `--json` — print the result as JSON.

**Examples:**

```bash
# Discover model ids for a provider
nodetool generate fake --list-models
nodetool generate fake fake-image "test fixture" -o fixture.png

nodetool generate fal-ai --list-models

# Text to image
nodetool generate fal-ai flux-schnell "a red fox in snow" -o fox.png

# Four square variants into a directory
nodetool generate fal-ai flux-schnell "a logo" --aspect-ratio 1:1 -n 4 -o ./out/

# Image to image
nodetool generate fal-ai flux-dev "restyle this" --image in.png --strength 0.6
```

## MCP Integration

### `nodetool mcp`

Register NodeTool as an MCP server in Claude Code, Codex, and OpenCode, check
the result, or run the server. See [NodeTool as an MCP Server](mcp-server.md)
for the setup guide.

**Subcommands:** `install`, `uninstall`, `status`, `config`, `serve`

**Examples:**

```bash
# Register the stdio server with every client found on this machine.
# Without a global install:
npx -y --package=@nodetool-ai/cli nodetool mcp install

# Register for Claude Code only
nodetool mcp install --claude

# Connect to a running server over HTTP instead of stdio
nodetool mcp install --http
nodetool mcp install --url http://127.0.0.1:8000/mcp

# Show the registered entries, then start each one and list its tools
nodetool mcp status
nodetool mcp status --check

# Print an mcpServers JSON block for Cursor, Claude Desktop, or another client
nodetool mcp config

# Remove from all clients
nodetool mcp uninstall
```

| Option | Applies to | Effect |
|---|---|---|
| `--claude`, `--codex`, `--opencode` | `install`, `uninstall` | Select clients. `install` without one selects every client found. |
| `--npx` | `install`, `config` | Launch through `npx` even when `nodetool` is on `PATH`. |
| `--http`, `--url <url>` | `install`, `config` | Write an HTTP entry. The default URL is `http://127.0.0.1:7777/mcp`. |
| `--no-verify` | `install` | Write the config without a test start of the server. |
| `--check` | `status` | Start each registered server and list its tools. |

`mcp status --check` exits 1 when a registered server fails to start, and
`mcp uninstall` exits 1 when a client config cannot be written.

`install` starts the server once before it writes a config, so a broken
command never reaches a client config. The stdio entry runs `nodetool mcp serve`
when the Node CLI is first on `PATH`, else
`npx -y --package=@nodetool-ai/cli nodetool mcp serve`.

HTTP MCP (`/mcp`) needs the API server (`nodetool serve`). `nodetool mcp serve`
uses stdio for the client and forwards to the local API server when its MCP
endpoint is available on port 7777 (or `PORT` when set). Actions then run in the API process, so
connected UI clients receive live updates. When the endpoint is unavailable,
the command runs its own local MCP server.

### What the MCP server exposes

The MCP mount offers these direct tools alongside the promoted discovery and
browser tools:

- **`execute_code`** — the CodeAct action tool, built by the same
  `createChatCodeActSession` the in-app chat agent runs on, so the two surfaces cannot drift.
  Everything NodeTool can do is reached from inside an action: workflow building and debugging
  (`create_workflow`, `validate_workflow`, `debug_workflow`, `debug_app`, the `ui_*`
  graph editing tools), media generation (`generate_image`, `generate_video`, `generate_speech`,
  `transcribe_audio`, …), files (`read_file`, `write_file`, `edit_file`, `glob`, `grep`), web
  (`web_search`, `browser`, `http_request`), collections, documents, code execution, image
  critique, and memory — as `nodetool.<namespace>.<method>()`, by importing them, or
  found with `await nodetool.searchTools("query")`. Google Workspace tools appear only on
  deployments with a Google login.
- **`view_image`** — direct, because image content cannot ride a sandbox action's JSON
  observation envelope.
- **`upload_asset`** — save base64 bytes in the user's asset library, or read an
  absolute path on the local NodeTool server. It returns the asset ID and URI.
- **`download_asset`** — return an owned asset's bytes as base64, or write them
  to an absolute path on the local NodeTool server. Remote MCP sessions use base64.

Inside `execute_code`, `import_asset` accepts an HTTP(S) URL or an absolute
server-local path on a local MCP connection. It returns an `asset://` URI,
byte size, and media duration when the source can be probed. Find its import
line with `nodetool.searchTools("import asset")`.

`render_demo_surface` renders a storyboard, script, sketch, timeline, graph, or 3D
surface from a source checkout with Remotion installed. It returns a silent MP4
asset, with the baked corner label hidden by default. The tool also accepts a
document cast ID. With both `from_ms` and `to_ms`, the exported clip plays that
source range at normal speed and has its duration. Without a range, it renders
the designed six-second loop. The 3D surface accepts ranges within its six-second
camera orbit.

MCP has no system prompt. The guest contract (QuickJS, not Node; `nodetool.*`;
no `finish()`) is the server `instructions` string and the first lines of the
`execute_code` description. The rest of the description is the CodeAct catalog.
Two resources carry the machine-readable form: `nodetool://capabilities` (tools
and modules) and `nodetool://sandbox` (blocked globals, unavailable bridges,
worked examples). Prompts `sandbox-action` and `sandbox-asset` are complete
`execute_code` bodies. Each skill the user can load is also a prompt with the
skill's name, and it returns the skill body.

The session needs a user to run as — its tools touch that user's secrets, assets, and files — so
`createMcpServer` refuses one that is not bound to a user (`nodetool mcp serve`, the local `/mcp`
mount, or an authenticated session). File tools read and write under a per-user workspace at
`<data-dir>/mcp-workspaces/<user-id>`, not the host filesystem.

## Agents

### `nodetool agent`

Run one CodeAct turn from the command line, over the default toolbelt. The
objective becomes the user message and the model acts by writing sandboxed
JavaScript — the same loop the chat runs. `create_plan` and `execute_plan` are
on the belt, so an objective that wants decomposing gets a task DAG and the
`planning_update` / `task_update` events stream with the rest of the trace.

**Subcommands:** `run`, `diagnose`

```bash
# Run an agent with an objective
nodetool agent run -p anthropic -m claude-sonnet-5 --objective "Research AI trends"

# Or pipe the objective
echo "Research AI trends" | nodetool agent run -p anthropic -m claude-sonnet-5

# Bound the tool-calling rounds
nodetool agent run -p openai -m gpt-5.4-mini -o "Summarize the README" \
  --max-iterations 8

# Ask before every write, execute or external call
nodetool agent run -p anthropic -m claude-sonnet-5 -o "Tidy the workflows" \
  --permission-mode default

# Bound what the run may spend and how long it may take
nodetool agent run -p openai -m gpt-5.4-mini -o "Research AI trends" \
  --cost-cap 0.50 --timeout 120

# Aggregate a failed run into one report
nodetool agent diagnose <job_id>
```

`--max-steps` is gone with the planner→compiler pipeline the command used to
run, along with plan approval, checkpoints, the planning/reasoning model split,
and skill auto-select. See [Agent CLI](agent-cli.md).

### Run budget

`--cost-cap <usd>` and `--timeout <s>` bound one run. They override two of the
five `NODETOOL_AGENT_*` settings a chat turn reads
(`NODETOOL_AGENT_TURN_COST_CAP_USD`, `NODETOOL_AGENT_TURN_DEADLINE_MS`); the
other three — `NODETOOL_AGENT_MAX_CONCURRENCY`, `NODETOOL_AGENT_MAX_TURNS`,
`NODETOOL_AGENT_UNPRICED_TOKEN_CEILING` — come from the settings alone.
`--cost-cap 0` lifts the dollar cap, which is what a local-only install wants;
`--timeout 0` leaves the run no time and stops it before its first model turn.

One budget covers the whole run, not each loop: a sub-agent, an `execute_plan`
DAG, and an `AgentNode` reached through `run_node` all reserve against it. A
cap is *admission*, so a turn whose worst case would cross it is refused before
the call rather than noticed after the money is spent — a model the price
catalog does not cover has no worst case and is admitted against a prompt-token
ceiling instead, never as free.

A run a ceiling stops prints the reason and exits non-zero:

```
agent stopped: turn budget of $0.01 reached
```

With `--json` the same reason arrives as an `error` event. Every run ends with
what it committed against the cap, as `spent $0.0123` on stderr or a
`log_update` in `--json`.

### Permission mode

Every tool call the model makes goes through one ladder, on every host. What a
mode decides:

| Mode | Read | Write, execute, external |
|---|---|---|
| `default` | runs | asks first |
| `auto` | runs | runs |
| `plan` | runs | blocked, with a message telling the model to switch out |

`--permission-mode` sets it on `nodetool agent run` and on `nodetool-chat`; an
unrecognized value is refused rather than falling back to a default.

**In a terminal**, `default` is what an unset flag means. Each write, execute or
external call waits for `y` (this call), `n` (refuse), or `a` (this tool for the
rest of the session). Chat displays the request in its fullscreen interface.
`nodetool agent run` prints it on stderr, leaving stdout for the run's result.

**Behind a pipe**, nobody is there to answer, so the run takes the headless gate:
`auto`, printing once up front that escalated calls are denied. An explicit mode
still applies — a piped `--permission-mode plan` blocks what plan mode blocks —
but the answer to anything the ladder escalates is `deny`, never a silent allow.

See the [Agent CLI](agent-cli.md) reference for full details.

> The `nodetool db` group (`migrate`, `status`, `baseline`, `rollback`) is documented under
> [Database Migrations](#database-migrations) above.

## Deployment and Workers

Two different things, and the CLI keeps them apart. A **server** is long-lived
and people connect in to it — `nodetool deploy` manages one over Docker. A
**worker** is a rented GPU that a NodeTool instance connects out to, bills by
the minute, and is meant to be torn down — `nodetool worker` provisions it. See
[Deployment](deployment.md) for which one you want.

### `nodetool deploy`

Manage a Docker self-host server target described by a `deployment.yaml`.
`init` scaffolds the file, `add` fills in one target, and every other
subcommand names that target.

The file lives in the user config directory, not the working directory:
`~/Library/Application Support/nodetool/deployment.yaml` on macOS,
`%APPDATA%\nodetool\deployment.yaml` on Windows, and
`$XDG_CONFIG_HOME/nodetool/deployment.yaml` — falling back to
`~/.config/nodetool/` — elsewhere. Run `nodetool deploy edit` to open it rather
than guessing the path.

**Subcommands:** `init`, `add`, `edit`, `list`, `show`, `plan`, `apply`,
`status`, `logs`, `destroy`, plus the remote group `workflows` and the
`users-*` verbs below.

**Options:**

- `--type <type>` — required on `add`. `docker` is the only supported value.
- `--dry-run` — print what `apply` would do without executing it.
- `-f, --follow` / `--tail <n>` / `--service <service>` — for `logs`; `--tail`
  defaults to `100`.
- `--force` — skip the confirmation on `destroy`, `workflows delete`, and `users-remove`.
- `--json` — machine-readable output on `list`, `workflows list`, and `users-list`.

```bash
# Scaffold, describe, and review before touching the remote host
nodetool deploy init
nodetool deploy add my-server --type docker
nodetool deploy plan my-server

# Deploy and watch it
nodetool deploy apply my-server
nodetool deploy logs my-server --follow --tail 200

# Open deployment.yaml in $EDITOR
nodetool deploy edit
```

`list` and `show` answer the two questions you have before naming a target in
any of the other verbs, and they are the pair to reach for when you have
inherited a `deployment.yaml` you did not write.

`list` queries each configured target for its live state and prints one row per
deployment — `name`, `type`, `status`, `last_deployed`, `host`, `container`,
`pod_id`, `project`, `region`, `service`. With none configured it prints
`(no deployments configured)`; `--json` gives the same rows for a script.
`show <name>` reads the file only, printing that one target's config as YAML —
no remote call, so it works while the host is down.

```bash
nodetool deploy list
nodetool deploy list --json
nodetool deploy show my-server
```

Both need the file to exist. Without it they exit non-zero with
`Deployment configuration not found at <path>. Run 'nodetool deploy init' to
create it.`

The full server walkthrough is [Deployment](deployment.md) and
[Self-Hosted Deployment](self-hosted-deployment.md).

#### Remote workflows

Once a target is up, `workflows` acts on the deployment's `/api/workflows`
routes rather than on the local database:

```bash
# Push a local workflow to the deployment, then run it there
nodetool deploy workflows sync my-server <workflow_id>
nodetool deploy workflows list my-server
nodetool deploy workflows run my-server <workflow_id> -p prompt="a red fox"
nodetool deploy workflows delete my-server <workflow_id>
```

`workflows run` takes `-p, --param <k=v>`, repeatable. `sync` pushes the
workflow row itself; the assets and models its nodes reference must already be
present on the target.

#### API users on the deployment

For a multi-user server, mint and rotate the tokens its API clients
authenticate with. `--role` is `user` unless you pass `admin`.

```bash
nodetool deploy users-add my-server alice --role admin
nodetool deploy users-list my-server
nodetool deploy users-reset-token my-server alice
nodetool deploy users-remove my-server alice
```

Every subcommand that touches a live deployment — the `users-*` verbs and the
`workflows` group above — sends an admin bearer token. `--token <token>`
passes it explicitly and wins over `NODETOOL_ADMIN_TOKEN`; with neither, an
interactive shell prompts and a non-interactive one exits `1`.

### `nodetool worker`

Provision, attach to, and tear down a rented GPU worker on RunPod or Vast. A
worker bills by the minute, so `stop` is part of the workflow, not cleanup you
get to postpone.

**Subcommands:** `profile` (`add`, `list`, `rm`), `create`, `list`, `status`,
`token`, `stop`, `models`

**Options:**

- `--target <runpod|vast>`, `--image <image>`, `--gpu <gpu>`, `--vcpu <n>` —
  what to rent, on `profile add` or inline on `create`.
- `--idle-timeout <minutes>` / `--max-lifetime <minutes>` — auto-stop when idle,
  and a hard TTL. Both are the guard against a forgotten worker.
- `--ssh-key <path>` — OpenSSH public key file. It exposes `22/tcp` so the worker can be debugged.
- `--disk <gb>` — persistent volume size, which holds the model cache (default `100`).
- `--token-policy <generate|fixed>` — default `generate`.
- `--profile <name>` / `--attach` — for `create`.
- `--all` — for `stop`, stops every non-stopped worker.
- `--json` — for `list` and `profile list`.

```bash
# Save a preset once, then rent from it
nodetool worker profile add hf-a40 --target runpod \
  --image ghcr.io/nodetool-ai/nodetool-worker:latest \
  --gpu "NVIDIA A40" --idle-timeout 15
nodetool worker create --profile hf-a40 --attach

# Inspect and tear down
nodetool worker list
nodetool worker status <instance-id>
nodetool worker stop --all
```

`token <id>` prints one worker's bearer token and nothing else — no table, no
label — so it pipes into the variable the bridge reads:

```bash
export NODETOOL_WORKER_TOKEN=$(nodetool worker token <instance-id>)
```

`list` withholds tokens on purpose; this is the one command that decrypts one,
and only for the worker you name. Reach for it when a second shell, a CI step,
or a `--api-url` client has to talk to a worker `--attach` did not configure. A
worker that carries no token is an error rather than empty output:
`Worker instance '<id>' has no token (open worker).`

The full walkthrough, including what `--attach` changes locally, is
[Worker Deployment](worker-deployment.md).

#### `nodetool worker models`

Manage the HuggingFace cache on the worker itself, over the WebSocket bridge —
no NodeTool server has to be running. Pre-pulling weights here is what keeps the
first run of a graph from paying the download on rented GPU time.

The `[worker-id]` argument is optional: omit it and the command uses the
currently attached worker, or errors telling you to attach one.

**Options:**

- `--repo-id <id>` — HuggingFace repo, `owner/name`. Required for `download`
  and `delete`.
- `--file-path <path>` — download a single file instead of the repo.
- `-a, --allow-patterns <pattern>` / `-i, --ignore-patterns <pattern>` — glob
  filters on `download`, both repeatable.
- `--json` — for `list`.

```bash
# What is already cached on the attached worker
nodetool worker models list

# Pull weights ahead of a run — whole repo, then just the safetensors
nodetool worker models download --repo-id stabilityai/sdxl-turbo
nodetool worker models download --repo-id stabilityai/sdxl-turbo -a "*.safetensors"

# Reclaim disk on a named worker
nodetool worker models delete <worker-id> --repo-id stabilityai/sdxl-turbo
```

## Messaging Bridges

### `nodetool telegram`

Run the Telegram bridge: it turns private-chat messages into turns on the agent
loop of a running NodeTool server, and streams the answers back. The bridge
holds no conversation state and no user credentials — the agent loop, tools,
permissions, threads, and cost tracking all stay on the server, which the
bridge reaches over `/ws` as the linked user.

Reach for it when you want NodeTool answering from a phone without opening the
web UI. The server it talks to must have `NODETOOL_INTEGRATION_TOKEN` set, or
the linking routes the bridge calls do not exist.

**Subcommands:** `serve`, `register-commands`

**Options:**

- `--config <path>` — path to `telegram-bot.json`, relative to the working
  directory. Both subcommands take it. Without the flag, `telegram-bot.json` is
  read if it is there and skipped if it is not; a path you asked for explicitly
  and that does not exist is an error.

Configuration is environment variables plus that optional file. The env vars are
listed in the
[Environment Variables Index](configuration.md#environment-variables-index);
the file carries only the tuning knobs:

```jsonc
// telegram-bot.json
{
  "allowUsers": [],       // Telegram user ids allowed to link; empty = anyone
  "editThrottleMs": 1500, // minimum gap between edits of one streamed reply
  "maxQueuedTurns": 3     // messages queued behind an in-flight turn
}
```

```bash
# Publish the bot's command list — a deploy step, not a boot step
export TELEGRAM_BOT_TOKEN=123456:AA...
export NODETOOL_INTEGRATION_TOKEN=the-same-value-the-server-has
nodetool telegram register-commands

# Long-poll the Bot API and serve turns
nodetool telegram serve --config ./telegram-bot.json
```

Both subcommands validate the whole configuration before doing anything and
print the fields that failed rather than a stack trace:

```
Invalid Telegram bridge environment:
  TELEGRAM_BOT_TOKEN: TELEGRAM_BOT_TOKEN is required
  NODETOOL_INTEGRATION_TOKEN: NODETOOL_INTEGRATION_TOKEN is required
```

`serve` polls with `getUpdates`. Webhook mode is not implemented yet, and
setting `TELEGRAM_WEBHOOK_URL` makes `serve` refuse to start rather than
silently polling. Design notes: [telegram-bot-design.md](telegram-bot-design.md).

## Browser Extension

### `nodetool extension`

Set up and check the bridge between NodeTool and the [Chrome extension](chrome-extension.md). The bridge needs no server: Chrome starts the native messaging host when the extension connects, and local clients reach it over a unix socket.

**Subcommands:** `install`, `status`

Neither subcommand takes options.

- `install` registers the native messaging host with Chrome, Chromium, Brave, Edge and Arc. It prints the wrapper path, each manifest it registered, and the extension ID.
- `status` prints `connected` or `not connected`, followed by one `socket: <path>` line for each bridge socket it finds. It exits with code 1 when the extension is not connected.

```bash
# Register the native host, then load or reload the extension in chrome://extensions
nodetool extension install

# Check the connection
nodetool extension status
```

## Development Tooling

### `nodetool affected [files...]`

Map changed files — or the git working tree — to the smallest set of workspaces
that must be rebuilt and tested: the workspace that owns each file plus its
downstream dependents, and a `build:packages` only when a package that loads
from `dist/` is in the set. It saves reaching for the full build after a
one-file change.

**Options:**

- `--base <ref>` — compare against a git ref instead of the working tree.
- `--json` — print the result as JSON.

**Examples:**

```bash
nodetool affected
nodetool affected --base main
nodetool affected packages/cli/src/nodetool.ts
nodetool affected --json
```

The output names each affected workspace and the commands to run:

```
1 affected workspace(s) from 1 changed file(s):
  changed   @nodetool-ai/cli

Suggested commands:
  npm run test --workspace=packages/cli
```

### `nodetool harness`

The machine-readable inventory behind harness-first engineering: every headless
harness in the repo, every product surface with the code paths it owns, and
which harnesses cover which surface. Shipping a new surface means adding it to
the registry with its harness — or with its debt written down.

**Subcommands:** `list`, `audit`, `capabilities`, `gate`

#### `nodetool harness list`

List every harness and its capabilities. Each command is marked as available
in the installed CLI or requiring a NodeTool checkout. `--json` adds a
`requiresCheckout` flag to each registry entry. Repository checks need the
checkout's scripts and workspace dependencies.

```bash
nodetool harness list
nodetool harness list --json
```

Each entry names the harness, its kind, what it covers, and how to invoke it:

```
  affected           meta      Changed-file → workspace mapping
                     nodetool affected [--base main] [json, no-db]

  packs-compile      static    Sandbox npm module compiler (bundle, scan, probe, cache) and the shipped bridge packs
                     nodetool packs compile [--json] [--force] [json, no-db]
```

#### `nodetool harness audit`

Audit surface coverage. A surface with no harness must carry a written gap note;
one without it fails the audit.

**Options:** `--json`, and `--strict` to exit non-zero while any gap remains.

```bash
nodetool harness audit
nodetool harness audit --strict
```

Covered surfaces list their harnesses; gaps print the note that justifies them:

```
  ok   sandbox-packages     packs-compile, validate
  GAP  electron-shell       Electron shell (windows, IPC, menus, auto-update)

Documented gaps:

  electron-shell: Covered by Jest unit tests only. A harness would boot the
  packaged shell headlessly (Playwright's Electron driver), assert the IPC
  surface, and reuse backend-smoke for the server half.
```

#### `nodetool harness capabilities`

The same invariant one rung down, over the agent capabilities rather than the
product surfaces: every exported capability must name the checked-in suites that
exercise it, the eval cases that drive a model through it, or a written gap note.
Reach for it after adding a capability — a new one with neither a check nor a
note fails here, and in `npm run capabilities:check`.

**Options:** `--json`, and `--strict` to exit non-zero while any gap remains.

```bash
nodetool harness capabilities
nodetool harness capabilities --strict
```

The header counts covered capabilities, then each gap prints the note that
justifies it:

```
Capability coverage: 216/224 covered, 8 gap(s)

  GAP  ui_get_graph                     ui
  GAP  get_apify_actor                  apify

Documented gaps:

  get_apify_actor: The Apify suites cover search, schema, run, abort, and the
  dataset reader; fetching one actor's record has no case. A read-only case
  against the recorded actor fixture would close it.
```

A trailing `Contract drift:` line means a capability's declared description,
schema, category, or `needsToolCallId` moved without its coverage mapping being
touched. Refresh the table with `npm run capabilities:sync` and say which case
covers the new contract.

#### `nodetool harness gate [files...]`

Run from a NodeTool Git checkout. Outside a checkout, the command exits with an
explanation and supports `--json` errors. For local documents in an installed
CLI, use `validate`, `jsscript test`, `app debug`, or `timeline debug`.

Map a diff onto surfaces by path and run the selfcheck of every harness covering
a touched surface. The diff selects the checks, not the author. Harnesses that
need a target or a key are printed as manual work rather than silently skipped.

**Options:**

- `--base <ref>` — diff against a git ref instead of the working tree.
- `--all` — ignore the diff and run every selfcheck.
- `--expensive` — include the expensive selfchecks (bundle staging and friends).
- `--dry-run` — print the plan without running anything.
- `--json` — print the plan, and the results unless `--dry-run`.
- `--strict` — exit non-zero when the diff touches a surface no harness covers.
- `--deps` — also touch surfaces whose paths sit in a workspace downstream of a
  changed one.
- `--include-suites` — also run `suiteOnly` selfchecks, which only re-run
  suites `npm run test:affected` runs for the same diff, and the workspace
  test steps dropped because `test:affected` runs their whole suite.
- `--jobs <n>` — run cheap selfchecks `n` at a time (default 1). Each check's
  output prints as one block when it finishes. Expensive checks run serially
  afterwards.
- `--timeout <seconds>` — kill a selfcheck that runs longer (default 900).

A changed root manifest, lockfile, root `tsconfig*.json`, `turbo.json`,
`.nvmrc`, or shared `scripts/run-*.mjs` launcher selects every selfcheck. The
plan step loads no built package, so `--dry-run` works before
`npm run build:packages`.

A workspace test step (`npm run test --workspace=<pkg> [-- filters]`) is
dropped from a selfcheck's `&&` chain when `test:affected` runs that
workspace's whole suite for the diff: any workspace on a global-file diff,
otherwise an affected backend package. The JSON output lists the dropped
steps as `prunedSuites`. Steps with other work, shell syntax, or unknown
workspaces still run. This assumes `test:affected` runs for the same diff.
`--all` alone does not enable these skips.
CI adds `--include-suites` when its `skip-tests` input disables package tests.

Check starts and completions print to stderr, including with `--json`.
Each result includes `durationMs`, while stdout keeps the JSON output.
CI uses `--jobs 1` and `NODETOOL_TEST_WORKERS=1` to run selfchecks and their
test files serially. This prevents competing selfchecks from exhausting
the game runtime’s wall-clock script batch budgets. Timeouts still apply.

```bash
nodetool harness gate --base main
nodetool harness gate --dry-run packages/base-nodes/src/index.ts
nodetool harness gate --all --expensive
```

The plan names the surfaces the diff touched before it runs anything:

```
1 changed file(s) touch 1 surface(s):
  workflow-execution   (1 file(s))

Manual harnesses (need a target/key — run yourself):
  debug              nodetool debug <id|file> [--browser --trace --watch --supervise]

3 selfcheck(s) to run
```

### `nodetool package`

Scaffold TypeScript node packages and inspect local Python package metadata.
`init` scaffolds a TypeScript package. `list`, `docs` and `node-docs` read
Python package metadata files. `workflow-docs` reads workflow JSON files.

[`nodetool packs`](#nodetool-packs) installs, removes and diagnoses published
node packs and runtime libraries, and compiles sandbox packs. Use
`nodetool node list` and `nodetool node info <type>` to discover the TypeScript
nodes available to this CLI.

**Subcommands:** `list`, `init`, `docs`, `node-docs`, `workflow-docs`

#### `nodetool package list`

List Python package metadata in the current directory and its workspaces,
with node counts. `-a, --available` lists the shipped Python pack catalog.
`--json` prints the rows as JSON. For installed TypeScript runtime packs use
`nodetool packs list`. For executable node types use `nodetool node list`.

```bash
nodetool package list
nodetool package list --available
nodetool package list --json
```

```
 name          │ version     │ description         │ nodes
───────────────┼─────────────┼─────────────────────┼───────
 nodetool-base │ 0.6.3-rc.41 │ Nodetool Base nodes │ 0
```

`--available` prints the Python packs in `PYTHON_NODE_PACKS` (`packages/protocol/src/python-packs.ts`). See
[Node Packs](node-packs.md) for what a pack is and how one is installed.

#### `nodetool package init`

Scaffold a new TypeScript node package in the current directory. Takes no
options — it prompts for what it needs.

```bash
mkdir nodetool-my-nodes && cd nodetool-my-nodes
nodetool package init
```

#### `nodetool package docs`, `node-docs`, `workflow-docs`

Generate Markdown from local Python package metadata: `docs` writes one
overview and `node-docs` writes a page per node. `workflow-docs` documents
workflow JSON files. Each takes `-v, --verbose`. An empty `node-docs` result
points to `nodetool node list` and `nodetool node info <type>` for installed
TypeScript node metadata, including properties and outputs.

**Options:**

- `docs` — `-o, --output-dir <dir>` (default `docs`), `-c, --compact` for a
  shorter overview.
- `node-docs` — `-o, --output-dir <dir>` (default `docs/nodes`),
  `-p, --package-name <name>` to emit only nodes under a namespace prefix.
- `workflow-docs` — `-o, --output-dir <dir>` (default `docs/workflows`),
  `-e, --examples-dir <dir>` (required) to point at the workflow JSONs, and
  `-p, --package-name <name>` to keep only those whose `package_name` matches.

```bash
nodetool package docs --compact
nodetool package node-docs --package-name nodetool.text
```

### `nodetool packs`

The npm CLI includes core, text, data, LLM, and code nodes. Install other node
packs and npm runtime libraries with `nodetool packs` in a global CLI
installation or an npm project. Temporary npx installations and development
checkouts support diagnostics but refuse installation changes.

| Command | Behavior |
|---|---|
| `packs compile [--json] [--force]` | Compile installed sandbox pack modules. See [sandbox compilation](#nodetool-packs-compile). |
| `packs list [--json]` | Show available packs, installation state and size estimates. |
| `packs add <ids...> [--yes]` | Install the pinned packs or runtime libraries and their requirements. |
| `packs add --restore [--yes]` | Restore the desired set recorded for this install root. |
| `packs add --for <workflow-or-file> [--yes]` | Install missing requirements of a saved workflow or an explicitly named local file. |
| `packs remove <ids...>` | Remove packages owned exclusively by the selection while preserving retained requirements. |
| `packs doctor [--json] [--installed] [--pack <ids...>]` | Check the mode, versions, integrity and installation state. Registration checks use a fresh child process. |
| `packs doctor --repair` | Repair a dirty installation after verifying its pinned cohort. |
| `packs why <node_type-or-id>` | Explain ownership, requirements and installation state. |

Add commands prompt with package versions, size estimates, approved scripts
and local manifest changes. Use `--yes` in a non-interactive terminal. Local
installation updates package.json and package-lock.json. Restart running
servers after adding or removing packs. After upgrading the CLI, use
`nodetool packs add --restore` to reinstall the recorded pack and runtime set
for that installation root.

`run`, `debug`, `node run` and `workflows run` accept `--install-missing`.
`NODETOOL_AUTO_INSTALL=1` also authorizes those commands. They install missing
npm requirements and re-execute once, forwarding the child exit code. Serve,
MCP and agent tools do not install packages automatically. External runtimes
such as ffmpeg and Python require their own setup.

See the [pack-system design](cli-pack-system-design.md#15-command-surface)
for installation boundaries and the platform matrix.

### `nodetool packs compile`

Compile every npm-backed sandbox module of every installed pack. A pack can
declare a guest module by npm dependency name instead of authoring code
(`{"name": ".", "kind": "js", "npm": "js-yaml"}`); this builds it — esbuild
bundles the dependency with no externals, a scope-aware scan rejects free
references to globals the guest lacks, and a capability-free QuickJS probe
imports the bundle to prove it initializes. Results are cached by content
digest, never by version.

The server compiles during its own catalog refresh and Electron compiles after
an install, so reach for this to warm the cache or to diagnose one pack.

**Options:**

- `--json` — print the compile report as JSON.
- `--force` — recompile and re-probe even when the cache has an answer.
- `--pack-search-path <dir>` — search this `node_modules` root (repeatable).

```bash
nodetool packs compile
nodetool packs compile --json
nodetool packs compile --force
```

Each module reports its source dependency and bundled size:

```
  ok   @nodetool-ai/sandbox-markdown ← marked — 66101 bytes
  ok   @nodetool-ai/sandbox-yaml ← js-yaml — 103863 bytes

10 compiled, 0 skipped.
```

Everything that stops a module short of admission is a named skip, not an
error: `npm-module-builtin-import` (the dependency needs `node:*`),
`npm-module-unresolved`, `npm-module-too-large` (1 MB cap),
`npm-module-forbidden-global`, and `npm-module-probe-failed`.

### `nodetool reliability`

Run a reliability journey on more than one execution surface and diff each one
against the kernel oracle. A journey is a small workflow plus the invariants its
run must hold — lifecycle pairing, one terminal message, no leaked cleanup — and
the point is that the kernel runner and the WebSocket server must produce the
same stream for it. Reach for it when a change touches execution and you need to
know whether the surfaces still agree.

Journeys live in `reliability/journeys/`.

Working in the monorepo, run this one from the built CLI (`npm run nodetool --`)
rather than from source: the journey fixtures use decorators, and the
`dev:nodetool` transform rejects them with `Decorators are not valid here`.

**Subcommands:** `list`, `run`, `update-goldens`

#### `nodetool reliability list`

List the journeys, each with its surfaces and the invariants it asserts. Takes
`--json` for the full summaries.

```bash
nodetool reliability list
nodetool reliability list --json
```

```
linear-text-pipeline
  Journey #1: input -> transform -> output.
  The baseline — if this diverges across surfaces, everything is suspect.
  surfaces: kernel, ws-server
  invariants: lifecycle-pairing, terminal-uniqueness
```

#### `nodetool reliability run <journey>`

Run one journey on the surfaces it declares and diff every non-oracle surface
against the kernel oracle.

**Options:**

- `--surface <name>` — run only this surface, repeatable, overriding the
  journey's own list. The kernel oracle is always included.
- `--faults <name>` — inject this fault, repeatable, replacing the journey's
  declared fault matrix. The provider-seam faults are implemented
  (`provider-429`, `provider-500`, `provider-timeout`, `truncated-stream`,
  `malformed-sse`, `slow-drip`, `cost-omission`); the `ws`/`bridge`/`host`/
  `client` names parse but report as unimplemented.
- `--diff` — print the full per-channel stream diff for a diverging surface.
- `--json` — print the whole `CompareReport` instead of the summary.

```bash
nodetool reliability run linear-text-pipeline
nodetool reliability run linear-text-pipeline --surface kernel
nodetool reliability run mid-run-cancel-node --faults provider-429 --diff
```

#### `nodetool reliability update-goldens <journey>`

Rewrite a journey's `expected/` fixtures from a fresh unfaulted kernel run. For
a golden that legitimately moved — read the diff before committing it, since
this command cannot tell a fixed bug from a new one.

```bash
nodetool reliability update-goldens linear-text-pipeline
```

### `nodetool eval <suite>`

Run one of the agent evaluation suites against a provider and model, and report
its metrics — success rate, expectation score, tool calls, duration, and cost.

**Suites:** `graph-planner`, `graph-e2e`, `code-gen`, `task-planner`, `subtask`,
`codeact`, `app-build`, `tool-loop`, `workflow-escalation`, `script-tools`,
`jsscript-tools`, `sketch-tools`, `timeline-tools`, `storyboard-tools`,
`model3d-tools`, `app-tools`, `memory-tools`, `creative-pipeline`.

**Options** (the same on every suite):

- `-p, --provider <id>` — provider id (`anthropic`, `openai`, `claude_agent_sdk`, `ollama`, …).
- `-m, --model <id>` — model id for that provider.
- `--cases <ids>` — comma-separated case ids to run (default: all).
- `--list` — list the suite's cases and exit.
- `--json` — print the full report as JSON.
- `--out <path>` — write the JSON report to a file.
- `--max-iterations <n>` — turn cap per case for the loop-style suites (default 12).
- `--timeout <ms>` — per-case execution timeout for the suites that run what they plan (default 300000).
- `--system-prompt <text>` — the caller preamble the planner suites plan behind (default: the AgentNode's own prompt).
- `--no-agent-prompt` — plan with no caller preamble, which scores the bare planner contract.
- `--judge-model <provider/model>` — the model that judges outputs in the self-judging suites (`graph-e2e`, `app-build`). Defaults to the run's own provider and model, which grades its own work.
- `--min-success <rate>` — exit non-zero when the success rate falls below this threshold (0..1).
- `--min-score <rate>` — exit non-zero when the mean expectation score falls below this threshold (0..1).
- `--min-cases <n>` — exit non-zero when fewer than `n` cases ran. Skipped cases do not count.
- `--max-cost-usd <usd>` — exit non-zero when the run's total provider spend exceeds this.
- `--max-p95-duration-ms <ms>` — exit non-zero when the p95 case duration, skipped cases excluded, exceeds this.
- `--keyless` — run only the cases that need no API key and no network (see `--list`).
- `--no-find-model` — run without configured model providers, skipping the model-dependent cases.

**Examples:**

```bash
# See what a suite covers before spending on it — needs no provider
nodetool eval graph-planner --list

nodetool eval graph-planner -p anthropic -m claude-sonnet-5
nodetool eval timeline-tools -p openai -m gpt-5.4-mini --cases cut-and-trim,titles-with-motion
nodetool eval graph-e2e -p anthropic -m claude-sonnet-5 --timeout 600000
nodetool eval codeact -p anthropic -m claude-sonnet-5 --json --out report.json

# Gate a run in CI
nodetool eval sketch-tools -p ollama -m qwen-3.5:4b --min-success 0.8
```

`--list` prints each case id with what it checks:

```
summarize                Single LLM step with one wired input and one output
branch-both-paths        Conditional with both If branches wired
deterministic-over-llm   Pure string mechanics must not be solved with an LLM step
```

### `nodetool jtbd`

An eval suite scores a model. A job asks whether the *product* let the agent
finish something, and keeps the transcript so you can say what to change.

A job is one objective taken end to end across whatever surfaces it needs,
stated the way a user would state it, handed to the agent in the user's own
words, and graded on the world it left behind. No job names a tool — which tool
the agent reaches for, and in what order, is what is under test. The worlds are
the same headless bridges the `tool-loop` eval suites drive.

**Subcommands:** `list`, `run`, `optimize`.

```bash
# What the jobs are, before spending anything — needs no provider
nodetool jtbd list
nodetool jtbd list --json
```

Each entry prints the job id, its difficulty tier, the surfaces it crosses, the
statement, and the outcomes it grades:

```
storyboard-a-scene  [standard]  storyboard
  When I have a scene to shoot, I want it broken into shots with the action
  described, so I can see the coverage before I spend on renders.
  outcomes: five-shots, action-written, savable
```

#### `nodetool jtbd run`

Drives a model through the jobs and writes one bundle per job.

- `-p, --provider <id>` — provider id (`anthropic`, `openai`, `ollama`, …). Required.
- `-m, --model <id>` — model id for that provider. Required.
- `--jobs <ids>` — comma-separated job ids (default: all; see `list`).
- `--out <dir>` — bundle directory (default `nodetool-debug/jtbd`).
- `--max-iterations <n>` — turn cap per job, for jobs that declare none.
- `--min-achieved <rate>` — exit non-zero when the achievement rate falls below this threshold (0..1).
- `--no-find-model` — run without configured model providers.
- `--json` — print the full report as JSON.

```bash
nodetool jtbd run -p anthropic -m claude-sonnet-5
nodetool jtbd run --jobs workflow-from-prompt,timeline-assemble-cut -p openai -m gpt-5.4-mini

# Gate a run in CI
nodetool jtbd run -p anthropic -m claude-sonnet-5 --min-achieved 0.8
```

`run` writes `nodetool-debug/jtbd/<job>/` holding `report.json` (transcript,
tool calls, outcomes, friction) and `review.md`, the same run rendered for a
person to read. The transcript is the point: it records what the model was told
and what it said between calls, not only which tools fired.

Before any model reviews a run, a pure pass derives what the transcript decides
on its own, and each finding names an owner. A tool that errored repeatedly, or
answered the same call identically three times, is a `harness` finding — the
fix is a schema or an error string in NodeTool's own code. A run that called no
tool at all is a `prompt` finding. A run that took far more calls than the job
needs is `unattributed`, because whether that is a prompt failing to describe
the short path or a tool surface forcing the long one is the judgement a pure
pass cannot make.

#### `nodetool jtbd optimize`

Hands one recorded run — the system prompt verbatim, every assistant turn,
every call with its arguments and result — to a *different* model and asks what
one change would make the next run go better.

- `-p, --provider <id>` — reviewing provider; use a different one than ran the jobs.
- `-m, --model <id>` — reviewing model id.
- `--bundle <dir>` — bundle directory to review (default `nodetool-debug/jtbd`).
- `--all` — review clean runs too, not just failures and friction.
- `--json` — print the proposals as JSON.

```bash
nodetool jtbd optimize -p openai -m gpt-5.4-mini
nodetool jtbd optimize -p openai -m gpt-5.4-mini --all --bundle ./my-runs
```

A proposal must name a target and a change; "improve the prompt" is rejected by
the parser. Proposals land in `proposals.json` next to the run.

**It proposes; it never applies.** Nothing in the loop edits a prompt or a
tool. Run and review are separate commands because the bundle is the handoff: a
bundle can be re-reviewed with a better optimizer without paying for the runs
again, and a person can read the transcript before any model proposes anything.

## Tips

- Use `--json` flags for machine-readable output suitable for scripting.
- Set `NODETOOL_API_URL` environment variable to avoid specifying `--api-url` on every command.
- Use `nodetool serve` to start the local backend server before running API commands.
- See [Environment Variables](configuration.md#environment-variables-index) for a complete list of configurable variables.
