From 552b41f7611a98014b611691a3cf2e6072bed574 Mon Sep 17 00:00:00 2001
From: Mikkel Garcia
Date: Tue, 28 Jul 2026 21:45:36 -0600
Subject: [PATCH 1/3] Drop the retired draw node from the agent-facing docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 🎨 Draw node retired on 2026-07-22 when its Gemini omni model left the
NanoGPT catalog. The runtime is correct: the editor skips the type and both
headless executors warn on load, then refuse the run. The documentation was
not correct. It still listed `draw` as a current node type, so an agent that
read these pages wrote a graph that cannot run.
- llms-full.txt is generated by scripts/gen-llms-full.mjs and was stale. The
3 `draw` mentions came from index.html and from the 2 library READMEs.
All 3 sources dropped `draw` already. Regenerated the file. The regeneration
also picks up unrelated README drift that had built up since the last run.
- guide/graph-format.html: removed the `draw` table row and the "LLM/Draw"
port note. Added a "Retired type" bullet under Loader rules instead. That
page documents the file format, so an author who holds a graph from before
2026-07-22 needs to know what the `draw` node in the JSON is and how to
repair it. Deletion alone would leave that author with no answer.
- guide/run-headless.html: removed `draw` from the runnable node list.
- growth/i18n-drafts/zenn-comfyui-ja.md: the unposted Japanese draft
advertised Draw, and described it wrongly as a hand-drawing input. Replaced
it with Join.
Historical records stay untouched: changelog.html, updates.json, feed.xml,
docs/ and proof/ still record that the node existed and then retired.
Co-Authored-By: Claude Opus 5 (1M context)
---
growth/i18n-drafts/zenn-comfyui-ja.md | 2 +-
guide/graph-format.html | 9 +-
guide/run-headless.html | 2 +-
llms-full.txt | 148 ++++++++++++++++++++++----
4 files changed, 135 insertions(+), 26 deletions(-)
diff --git a/growth/i18n-drafts/zenn-comfyui-ja.md b/growth/i18n-drafts/zenn-comfyui-ja.md
index 3b844d4..6b29bb2 100644
--- a/growth/i18n-drafts/zenn-comfyui-ja.md
+++ b/growth/i18n-drafts/zenn-comfyui-ja.md
@@ -78,7 +78,7 @@ https://nanoodle.com/ja/ を開くとエディタがそのまま起動します
### 2. ノードを置いてつなぐ
-テキスト・画像・動画・音声のモデルノードに加えて、Draw(手描き入力)、
+テキスト・画像・動画・音声のモデルノードに加えて、Join(テキスト結合)、
Resize、Choice、Comment などのユーティリティノードがあります。
たとえば:
diff --git a/guide/graph-format.html b/guide/graph-format.html
index 31423e0..ee19b7b 100644
--- a/guide/graph-format.html
+++ b/guide/graph-format.html
@@ -129,7 +129,7 @@ Ports and wires
- Declared inputs — fixed per type (e.g.
inpaint takes
image and mask), plus dynamic families that grow as you wire
- more: img1, img2, … (LLM/Draw vision references),
+ more: img1, img2, … (LLM vision references),
image, image2, … (Edit references), ref1, …
(Text→Video references), clip1, … (Combine), and endframe
(Image→Video) / audio (LLM audio input).
@@ -155,7 +155,6 @@ Node types
join | Join | local | a, b (text) | text | sep |
llm | LLM | NanoGPT | img1… (image), audio | text | model, system, prompt, temperature, maxTokens, format, reasoningEffort, showThinking |
image | Image | NanoGPT | — | image | model, prompt, size, variations, seed, customCivitaiAir, negativePrompt |
- draw | Draw | NanoGPT | img1… (image) | image, text | model, system, prompt, showThinking |
edit | Edit | NanoGPT | image, image2… | image | model, prompt, size, seed |
inpaint | Inpaint | NanoGPT | image, mask | image | model, prompt, size, seed |
resize | Resize / crop | browser-only | image | image | mode, width, height |
@@ -186,6 +185,12 @@ Loader rules
had a single combined audio node).
- Unknown node types don't crash a load — the editor skips them; the headless
libraries keep them, warn, and fail fast at run time.
+ - Retired type:
draw (🎨 Draw) retired on 2026-07-22, because its
+ Gemini omni model left the NanoGPT catalog. It is not in the table above and you
+ cannot add it. A graph saved before that date still loads — the editor skips the
+ node and the headless libraries warn on load, then refuse the run. To repair such a
+ graph, replace the draw node with image (generate) or
+ edit (edit an image you wire in).
- Keep a link only if both endpoint nodes exist after filtering.
- Migration: a link into a
music or tts node's
"text" port is rewritten to "prompt".
diff --git a/guide/run-headless.html b/guide/run-headless.html
index 32feb78..e2b3540 100644
--- a/guide/run-headless.html
+++ b/guide/run-headless.html
@@ -132,7 +132,7 @@ Inputs, outputs, settings
What runs where
Pure-logic nodes (text, uploads, choice, join, comment) run locally; generation nodes
- (llm, image, draw, edit, inpaint, vision, video, lipsync, music, remix, tts, transcribe)
+ (llm, image, edit, inpaint, vision, video, lipsync, music, remix, tts, transcribe)
call NanoGPT. Browser-only media-processing nodes (resize, vframes, combine, soundtrack,
trim, extractaudio) are not supported headlessly: workflows containing them load
with a warning and fail fast at run(), before any network call. The full
diff --git a/llms-full.txt b/llms-full.txt
index be9863d..435cdc3 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -23,7 +23,8 @@ self-contained `.html` file.
### Privacy architecture
-nanoodle is three static HTML pages. There is no backend, and that is the
+nanoodle is a folder of static HTML pages — three core pages plus a
+changelog, guides and landing pages. There is no backend, and that is the
design, not a limitation:
- **No analytics, no tracking, no third-party scripts.** Nothing phones home.
@@ -97,6 +98,16 @@ Replace the code with your own (or delete the parameter and use bare
[patchling](https://github.com/255BITS/patchling), share via `#a=`
link, or export a self-contained `.html`.
- **`legal.html`** — terms, privacy, FAQ (`/legal`).
+- **`changelog.html`** — release notes (`/changelog`), generated from
+ `updates.json` by `scripts/gen-changelog.mjs`, plus an Atom feed
+ (`feed.xml`).
+- **`guide/`** — static docs (`/guide/`): the `noodle-graph.json` format,
+ running workflows headlessly, self-hosting, and how share links work.
+ Zero scripts, zero network.
+- The rest is landing pages: comparisons (`nanoodle-vs-comfyui.html`,
+ `nanoodle-vs-n8n.html`, `comfyui-alternative.html`,
+ `comfyui-alternative-no-gpu.html`) and localized home pages (`es/`,
+ `fr/`, `de/`, `pt/`, `ja/`).
### Run workflows from code
@@ -116,6 +127,27 @@ const result = await wf.run({ Text: "a cozy ramen shop on a rainy night" });
await result.get("Image").save("ramen.png");
```
+### Ecosystem
+
+Everything lives under the [nanoodlecom](https://github.com/nanoodlecom) GitHub org:
+
+| Repo | What it is |
+| --- | --- |
+| [nanoodle](https://github.com/nanoodlecom/nanoodle) | The playground — editor, app builder, the whole site (this repo) |
+| [nanoodle-js](https://github.com/nanoodlecom/nanoodle-js) | Zero-dependency JS executor — use when running saved graphs from Node or the CLI |
+| [nanoodle-py](https://github.com/nanoodlecom/nanoodle-py) | Zero-dependency Python executor — use when your scripts speak Python |
+| [nanoodle-mcp](https://github.com/nanoodlecom/nanoodle-mcp) | MCP server exposing saved graphs as tools — use when an AI agent should run your workflows |
+| [run-noodle-action](https://github.com/nanoodlecom/run-noodle-action) | GitHub Action — use when a graph should run in CI |
+| [nanoodle-skill](https://github.com/nanoodlecom/nanoodle-skill) | One agent skill that teaches an agent to build any graph — use when the agent should author workflows itself |
+| [noodle-skills](https://github.com/nanoodlecom/noodle-skills) | Prebuilt one-task agent skills — use when you want a ready-made skill, no graph editing |
+| [awesome-noodles](https://github.com/nanoodlecom/awesome-noodles) | Gallery of ready-to-open graphs — use when you want examples to start from |
+| [built-with-nanoodle](https://github.com/nanoodlecom/built-with-nanoodle) | Showcase of apps people shipped — use when you want to see (or show) what nanoodle makes |
+| [noodle-embed](https://github.com/nanoodlecom/noodle-embed) | `` web component — use when a page should render a workflow read-only |
+| [mp4cat](https://github.com/nanoodlecom/mp4cat) | Lossless mp4 concat, browser or Node — use when you just need to join clips, no ffmpeg |
+
+Naming note: the package is `nanoodle` on **both** registries while the repos are
+`nanoodle-js` / `nanoodle-py` — so it's `npm install nanoodle` and `pip install nanoodle`.
+
### Development
No build step. Edit the HTML files, refresh the browser.
@@ -126,6 +158,24 @@ bundler, the OAuth flow, run-engine compatibility, pricing, i18n coverage
and more. They spend no API credits — everything runs against recorded
fixtures.
+`scripts/check-js-parity.mjs` dual-runs the same graphs through play.html’s
+`RUNTIME_JS` and the sibling [`nanoodle-js`](https://github.com/nanoodlecom/nanoodle-js)
+package and asserts identical NanoGPT request bodies — the safety net for
+eventually replacing the inlined processor with the package. Skips if
+`nanoodle-js` isn’t checked out next to this repo (or set `NANOODLE_JS`).
+
+play.html also embeds a generated bundle of that package (the `njs-engine`
+script block, `scripts/gen-js-engine.mjs`, freshness-checked pre-commit)
+and routes network nodes through it by default — `?engine=play` (or
+`localStorage.njs_engine = "0"`) opts back into the built-in runners;
+`scripts/check-njs-delegation.mjs` asserts the delegated path produces
+byte-identical requests. Exported apps carry the bundle too, so the same
+flag works there. The editor honors the same flag: index.html lazy-loads
+the same bundle as `vendor/njs-engine.js` (emitted by the same generator),
+and known library gaps are vetoed back to the built-in runners per run;
+`scripts/check-njs-editor-delegation.mjs` pins that path to the built-in
+runners byte-for-byte.
+
`updates.json` is the in-app changelog behind the 📣 button. It's opt-in
per commit: add an `Update: one polished line` to a commit message and the
`post-commit` hook folds it in. Commits without one stay silent. Edit the
@@ -137,7 +187,7 @@ JSON by hand anytime; `scripts/check-updates.mjs` keeps it valid.
## Node types in the editor
-The editor registers 27 node types. Titles and one-line descriptions below are taken verbatim from the node registry in index.html (the same text the in-editor quick-add menu shows); the `key` is the type identifier used in noodle-graph.json and in the executor libraries' supported-node tables.
+The editor registers 26 node types. Titles and one-line descriptions below are taken verbatim from the node registry in index.html (the same text the in-editor quick-add menu shows); the `key` is the type identifier used in noodle-graph.json and in the executor libraries' supported-node tables.
### Inputs
@@ -155,7 +205,6 @@ The editor registers 27 node types. Titles and one-line descriptions below are t
### Image
- `image` — 🖼 Image: Text → image
-- `draw` — 🎨 Draw: Prompt (+ images) → image — Gemini omni: reasons, then draws or edits
- `edit` — ✂️ Edit: Image(s) + text → image
- `inpaint` — 🩹 Inpaint: Brush a region of an image → repaint just that area
- `resize` — 📐 Resize / crop: Image → image — scale, crop, fit an aspect
@@ -223,6 +272,8 @@ npx nanoodle --help # or run the CLI without installing
import { Workflow } from "nanoodle";
const wf = await Workflow.load("noodle-graph.json"); // key from NANOGPT_API_KEY
+// …or load any nanoodle share link — the URL is the package:
+// const wf = await Workflow.load("https://nanoodle.com/#g=…");
const result = await wf.run({ Text: "a cozy ramen shop on a rainy night" });
await result.get("Image").save("ramen.png"); // media: MediaRef (url + bytes()/save())
console.log(result.costUsd, result.remainingBalance);
@@ -232,39 +283,66 @@ With the app’s starter graph (text → LLM prompt-writer → image), that’s
### Quickstart (CLI)
-Inspect first — offline; shows inputs, outputs, and settings:
+No graph yet? Scaffold the starter (text → LLM prompt-writer → image) and
+inspect it — both offline, no key needed:
```bash
-npx nanoodle inspect graph.json
+npx nanoodle init # writes ./noodle-graph.json
+npx nanoodle inspect noodle-graph.json # shows inputs, outputs, settings
```
Then run (calls NanoGPT and spends from your balance):
```bash
export NANOGPT_API_KEY=... # or --key K, or --env-file .env
-npx nanoodle run graph.json --input Text="a cozy ramen shop" --out ./out
-npx nanoodle run graph.json --input n2.system=@style.txt --set n3.size=1k --json
+npx nanoodle run noodle-graph.json --input Text="a cozy ramen shop on a rainy night"
+npx nanoodle run graph.json --input n2.system=@style.txt --set n3.size=1k --out ./renders
```
-- `--env-file path` — load `NANOGPT_API_KEY` from a `.env`-style file (`--key` wins if both are set)
+Media outputs are saved under `--out` (default `./noodle-out`, created only
+when there is media to save); a JSON run summary always goes to stdout,
+progress lines to stderr; exit code `0` on success, `1` on failure.
+
- `--input k=@path` — read a file (media as media; `.txt` / `.md` / `.json` as text)
-- `--out dir` — save media outputs to disk
-- `--json` — machine-readable result
+- `--set k=v` — override a setting (`n3.model=flux-pro`)
+- `--out dir` — where media outputs land (default `./noodle-out`)
+- `--timeout ms` — overall run timeout
+- `--json` — quiet mode: skip the stderr progress lines (the JSON summary is printed either way)
+- `--env-file path` — load `NANOGPT_API_KEY` from a `.env`-style file (`--key` wins if both are set)
+
+#### The URL is the package
+
+Every nanoodle share link is a runnable artifact — paste one straight from a
+README, a chat, or a tweet, wherever a `graph.json` path is accepted:
+
+```bash
+npx nanoodle inspect "https://nanoodle.com/#g=..." # what does it need? (offline)
+npx nanoodle run "https://nanoodle.com/play.html#a=..." --input Text=hi # run it
+```
+
+Workflow links (`#g=`/`#j=`) and app links (`#a=`, graph only — the app shell
+stays in the browser) both work, as do da.gd/TinyURL short links (resolved by
+reading redirect headers; no credentials are ever sent). Direct links decode
+fully offline. Quote the URL — `#` starts a comment in most shells.
+
+Links mangled in transit (a character flipped or dropped by a chat app or a
+copy/paste) are recovered best-effort: the graph's nodes and wires are salvaged
+and a warning is printed. Only damage inside the graph itself makes a link
+unrecoverable.
### Supported nodes
| runs | node types |
|---|---|
| local | text, upload (image/audio/video), choice, join, comment |
-| NanoGPT | llm (incl. vision + audio input), image, draw, edit, inpaint*, vision, tvideo, ivideo, vedit, lipsync, music, remix, tts, transcribe |
-| **not supported** (browser-only media processing) | resize, vframes, combine, soundtrack, trim, extractaudio |
+| local media† | resize, vframes, combine, soundtrack, trim, extractaudio |
+| NanoGPT | llm (incl. vision + audio input), image, edit, inpaint*, vision, tvideo, ivideo, vedit, lipsync, music, remix, tts, transcribe |
-Workflows with unsupported node types load with a warning and fail fast at
-`run()` with `UnsupportedNodeError` — before any network call.
+† **local media** prefers a pure-JS path that matches the browser (lossless mp4 remux, PCM-WAV trim, PNG resize). **ffmpeg** on `PATH` is the fallback for everything else (soft dependency — not an npm package); clear error if it’s required and missing.
-\* **inpaint:** the browser app composites the mask onto black at the source
-pixel size; this library passes your mask through verbatim. Supply a
-black/white mask matching the source dimensions.
+\* **inpaint:** the mask is composited onto black at the source image’s pixel
+size before send (same as the browser app) — white = repaint, black = keep.
+Brush-style white-on-transparent masks and plain B/W masks both work.
## nanoodle-py — run workflows from Python
@@ -278,6 +356,7 @@ your own key.
Zero runtime dependencies (stdlib only). Library + CLI in one install.
Looking for JavaScript / Node? → **[nanoodle-js](https://github.com/nanoodlecom/nanoodle-js)**
+Running graphs in GitHub CI → [run-noodle-action](https://github.com/nanoodlecom/run-noodle-action) · saved graphs as AI-agent tools → [nanoodle-mcp](https://github.com/nanoodlecom/nanoodle-mcp) · Agent Skills → [nanoodle-skill](https://github.com/nanoodlecom/nanoodle-skill) / [noodle-skills](https://github.com/nanoodlecom/noodle-skills)
### At a glance
@@ -299,6 +378,9 @@ pip install nanoodle
export NANOGPT_API_KEY=... # nano-gpt.com API key (or OAuth access token)
```
+Requires **0.2.0+** for share-link loading, the local media nodes
+(resize/vframes/combine/soundtrack/trim/extractaudio), and x402 `--pay`.
+
### Quickstart (library)
```python
@@ -306,12 +388,34 @@ from nanoodle import Workflow
wf = Workflow.load("noodle-graph.json")
result = wf.run({"Text": "a cozy ramen shop on a rainy night"})
-result["Image"].save("ramen.png") # media: MediaRef (url + bytes()/save())
+img = result["Image"] # media: MediaRef (url + bytes()/save())
+img.save("ramen." + img.suggested_extension()) # extension matches the actual MIME (often jpg)
print(result.cost_usd, result.remaining_balance)
```
With the app’s starter graph (text → LLM prompt-writer → image), that’s the whole program.
+#### The URL is the package
+
+Every nanoodle share link is a runnable artifact. Anywhere a `graph.json` path
+is accepted — `Workflow.load` or the CLI — a share link works just as well:
+
+```python
+wf = Workflow.load("https://nanoodle.com/#g=...") # workflow link
+wf = Workflow.load("https://nanoodle.com/play.html#a=...") # app link (graph only)
+```
+
+Workflow links (`#g=`/`#j=`) and app links (`#a=`, graph only — the app shell
+stays in the browser) both decode, as do `da.gd`/TinyURL short links (resolved
+by reading redirect headers; no credentials are ever sent). Direct fragment
+links decode **fully offline** — zero network I/O, stdlib only. Paste one
+straight from a README, a chat, or a tweet.
+
+Links mangled in transit (a character flipped or dropped by a chat app or a
+copy/paste) are recovered best-effort: the graph's nodes and wires are salvaged
+and a warning is surfaced on `wf.warnings`. Only damage inside the graph itself
+makes a link unrecoverable.
+
#### Discover a workflow’s interface
```python
@@ -364,6 +468,7 @@ nanoodle-py inspect graph.json
nanoodle-py run graph.json --input Text="a cozy ramen shop" --set n3.size=1k --out ./out
nanoodle-py run graph.json --input n2.system=@style.txt --json
nanoodle-py run graph.json --env-file .env --input Text="hello" # NANOGPT_API_KEY from a .env file
+nanoodle-py inspect "https://nanoodle.com/#g=..." # a share link works too (quote it — # is a shell comment)
```
- `--out DIR` — save media outputs to files
@@ -375,11 +480,10 @@ nanoodle-py run graph.json --env-file .env --input Text="hello" # NANOGPT_API_
| runs | node types |
|---|---|
| local | text, upload (image/audio/video), choice, join, comment |
-| NanoGPT | llm (incl. vision + audio input), image, draw, edit, inpaint*, vision, tvideo, ivideo, vedit, lipsync, music, remix, tts, transcribe |
-| **not supported** (browser-only media processing) | resize, vframes, combine, soundtrack, trim, extractaudio |
+| local media† | resize, vframes, combine, soundtrack, trim, extractaudio |
+| NanoGPT | llm (incl. vision + audio input), image, edit, inpaint*, vision, tvideo, ivideo, vedit, lipsync, music, remix, tts, transcribe |
-Workflows with unsupported node types load with a warning and fail fast at
-`run()` with `UnsupportedNodeError` — before any network call.
+† **local media** needs **ffmpeg** on `PATH` (soft dependency — not a PyPI package). Same behaviour as the browser app; clear error if ffmpeg is missing.
\* **inpaint:** the browser app composites the mask onto black at the source
pixel size; this library passes your mask through verbatim. Supply a
From 830cc558d971e6dc642127b0d5ae8db5bc3edcc0 Mon Sep 17 00:00:00 2001
From: Mikkel Garcia
Date: Tue, 28 Jul 2026 22:27:48 -0600
Subject: [PATCH 2/3] guide: local media nodes DO run headlessly; guard
llms-full.txt freshness
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two agent-facing guide pages said the opposite of what the executors do, and
llms-full.txt in the same commit said the truth.
guide/run-headless.html: "Browser-only media-processing nodes (resize,
vframes, combine, soundtrack, trim, extractaudio) are not supported
headlessly: workflows containing them load with a warning and fail fast at
run(), before any network call."
guide/graph-format.html: "the headless executors reject them up front, before
any paid call."
Both are false. The truth, read off the executors:
- nanoodle-js src/local-media.mjs implements all 6 ops headlessly: a pure-JS
path first (PNG resize through its own PNG codec, PCM-WAV trim, MP4CAT
lossless mp4 concat), then ffmpeg/ffprobe on PATH for everything else.
- nanoodle-py src/nanoodle/local_media.py has no pure path. All 6 shell out to
ffmpeg.
- Neither library marks any node type unsupported. nanoodle-py's
graph.UNSUPPORTED_TYPES evaluates to the empty tuple; nanoodle-js lists all 6
as local:true in graph.mjs. UnsupportedNodeError is raised only for an
UNKNOWN node type (workflow.mjs:183-188, workflow.py:208-215) — which is what
a legacy `draw` node is, and the only case the up-front refusal covers.
A false "not supported" costs an agent a capability it has, so run-headless.html
now carries a per-node, per-language table (which of the 6 are pure, which need
ffmpeg), and says when the missing-ffmpeg error actually arrives: at that node's
turn, after any upstream paid node has already spent. graph-format.html's "runs"
column reads "local media" instead of "browser-only" and points at that table.
Freshness guard. Nothing stopped llms-full.txt from going stale again: no
check-*.mjs mentioned it and gen-llms-full.mjs was not in the hook. Worse, its
clone() returned the cached .tmp-libs/ whenever README.md existed and
never re-fetched, so a regeneration could silently reproduce a months-old
library README. Today's file was correct by luck.
- gen-llms-full.mjs now hard-resets the cached clone to the remote default
branch on every run, and fails loudly (naming the fix) when it cannot.
--offline reuses the cache and says so; --check regenerates and diffs instead
of writing; NANOODLE_JS / NANOODLE_PY read a local checkout instead of
cloning. Its parts are exported so a guard can rebuild them.
- scripts/check-llms-full.mjs is offline and deterministic. It regenerates
every section sourced from this repo (llms.txt, README.md, index.html) and
diffs byte for byte, then asserts every node type named in the two libraries'
supported-node tables is still registered in index.html — the exact check the
three stale `draw` rows would have failed.
- .githooks/pre-commit runs it when llms-full.txt, llms.txt, README.md,
index.html or either script is staged.
- .github/workflows/checks.yml checks out nanoodle-py as well and runs
gen-llms-full.mjs --check against both fresh siblings, covering the library
sections the offline guard cannot rebuild.
llms-full.txt itself is unchanged: it already matched. That is now proven, not
lucky — `node scripts/gen-llms-full.mjs --check` passes against freshly fetched
clones of both libraries.
Co-Authored-By: Claude Opus 5 (1M context)
---
.githooks/pre-commit | 16 +++-
.github/workflows/checks.yml | 16 ++++
guide/graph-format.html | 19 ++--
guide/run-headless.html | 28 +++++-
scripts/check-llms-full.mjs | 93 +++++++++++++++++++
scripts/gen-llms-full.mjs | 175 +++++++++++++++++++++++++++--------
6 files changed, 293 insertions(+), 54 deletions(-)
create mode 100644 scripts/check-llms-full.mjs
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index f0f5c93..d0bc26b 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -120,9 +120,12 @@ touches_consent=$(printf '%s\n' "$staged" | grep -E '(^|/)(play\.html|scripts/ch
# play.html bakes creator sample results into #a= shares so keyless recipients see what the app makes;
# index.html's buildShareUrl("app") packs the same envelope (samples + lang parity, asserted by the checker).
touches_sharesamples=$(printf '%s\n' "$staged" | grep -E '(^|/)(index\.html|play\.html|scripts/check-share-samples\.mjs)$' || true)
+# llms-full.txt is generated and says "do not edit by hand", so only a re-run of the generator keeps it
+# true. It once advertised the retired draw node for days after every source had dropped it.
+touches_llmsfull=$(printf '%s\n' "$staged" | grep -E '(^|/)(llms-full\.txt|llms\.txt|README\.md|index\.html|scripts/(gen-llms-full|check-llms-full)\.mjs)$' || true)
# Nothing to check unless an .html file or one of the checkers changed.
-[ -z "$staged_html" ] && [ -z "$touches_njseditor" ] && [ -z "$touches_llmgate" ] && [ -z "$touches_export" ] && [ -z "$touches_login" ] && [ -z "$touches_sw" ] && [ -z "$touches_compat" ] && [ -z "$touches_run" ] && [ -z "$touches_jsparity" ] && [ -z "$touches_seedcache" ] && [ -z "$touches_imgports" ] && [ -z "$touches_langpages" ] && [ -z "$touches_editorports" ] && [ -z "$touches_settings" ] && [ -z "$touches_quickadd" ] && [ -z "$touches_i18n" ] && [ -z "$touches_updates" ] && [ -z "$touches_createapp" ] && [ -z "$touches_stale" ] && [ -z "$touches_vresume" ] && [ -z "$touches_lipsync" ] && [ -z "$touches_aresume" ] && [ -z "$touches_share" ] && [ -z "$touches_pricing" ] && [ -z "$touches_combine" ] && [ -z "$touches_pacing" ] && [ -z "$touches_graphpersist" ] && [ -z "$touches_graphsigs" ] && [ -z "$touches_describeapply" ] && [ -z "$touches_authlc" ] && [ -z "$touches_mediapriv" ] && [ -z "$touches_verhist" ] && [ -z "$touches_rungating" ] && [ -z "$touches_appsstorage" ] && [ -z "$touches_linkerr" ] && [ -z "$touches_deploy" ] && [ -z "$touches_hiddencss" ] && [ -z "$touches_connect" ] && [ -z "$touches_undo" ] && [ -z "$touches_costaccrue" ] && [ -z "$touches_resize" ] && [ -z "$touches_catalog" ] && [ -z "$touches_upscale" ] && [ -z "$touches_drift" ] && [ -z "$touches_promptcaps" ] && [ -z "$touches_consent" ] && [ -z "$touches_sharesamples" ] && [ -z "$touches_examples" ] && exit 0
+[ -z "$staged_html" ] && [ -z "$touches_njseditor" ] && [ -z "$touches_llmgate" ] && [ -z "$touches_export" ] && [ -z "$touches_login" ] && [ -z "$touches_sw" ] && [ -z "$touches_compat" ] && [ -z "$touches_run" ] && [ -z "$touches_jsparity" ] && [ -z "$touches_seedcache" ] && [ -z "$touches_imgports" ] && [ -z "$touches_langpages" ] && [ -z "$touches_editorports" ] && [ -z "$touches_settings" ] && [ -z "$touches_quickadd" ] && [ -z "$touches_i18n" ] && [ -z "$touches_updates" ] && [ -z "$touches_createapp" ] && [ -z "$touches_stale" ] && [ -z "$touches_vresume" ] && [ -z "$touches_lipsync" ] && [ -z "$touches_aresume" ] && [ -z "$touches_share" ] && [ -z "$touches_pricing" ] && [ -z "$touches_combine" ] && [ -z "$touches_pacing" ] && [ -z "$touches_graphpersist" ] && [ -z "$touches_graphsigs" ] && [ -z "$touches_describeapply" ] && [ -z "$touches_authlc" ] && [ -z "$touches_mediapriv" ] && [ -z "$touches_verhist" ] && [ -z "$touches_rungating" ] && [ -z "$touches_appsstorage" ] && [ -z "$touches_linkerr" ] && [ -z "$touches_deploy" ] && [ -z "$touches_hiddencss" ] && [ -z "$touches_connect" ] && [ -z "$touches_undo" ] && [ -z "$touches_costaccrue" ] && [ -z "$touches_resize" ] && [ -z "$touches_catalog" ] && [ -z "$touches_upscale" ] && [ -z "$touches_drift" ] && [ -z "$touches_promptcaps" ] && [ -z "$touches_consent" ] && [ -z "$touches_sharesamples" ] && [ -z "$touches_llmsfull" ] && [ -z "$touches_examples" ] && exit 0
if ! command -v node >/dev/null 2>&1; then
echo "pre-commit: node not found, skipping HTML JS checks" >&2
exit 0
@@ -486,3 +489,14 @@ fi
if [ -n "$touches_promptcaps" ]; then
node "$root/scripts/check-prompt-caps.mjs"
fi
+
+# 41) LLMS-FULL FRESHNESS: llms-full.txt is the single-file reference agents fetch, it is generated,
+# and it says "do not edit by hand" — so nothing keeps it true except somebody re-running the
+# generator. Nobody did, and it advertised the retired draw node after every source had dropped
+# it. Offline: regenerates the sections sourced from THIS repo (llms.txt, README.md, index.html)
+# and diffs them byte for byte, then asserts every node type named in the two libraries'
+# supported-node tables is still registered in index.html. Regenerate with
+# `node scripts/gen-llms-full.mjs`. CI diffs the library sections too, against fresh checkouts.
+if [ -n "$touches_llmsfull" ]; then
+ node "$root/scripts/check-llms-full.mjs"
+fi
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index b659beb..42ef312 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -22,6 +22,12 @@ jobs:
repository: nanoodlecom/nanoodle-js
path: nanoodle-js-sibling
+ - name: Checkout nanoodle-py (llms-full.txt source)
+ uses: actions/checkout@v4
+ with:
+ repository: nanoodlecom/nanoodle-py
+ path: nanoodle-py-sibling
+
- uses: actions/setup-node@v4
with:
node-version: 22
@@ -45,3 +51,13 @@ jobs:
fails=$((fails+1))
fi
exit $fails
+
+ # The pre-commit guard can only diff the sections llms-full.txt takes from THIS
+ # repo; the two library sections need the sibling READMEs. Here they are checked
+ # out fresh, so this is the check that catches a library README moving on without
+ # a regeneration. Reads the siblings from disk — no clone, no network.
+ - name: llms-full.txt matches the library READMEs
+ env:
+ NANOODLE_JS: ${{ github.workspace }}/nanoodle-js-sibling
+ NANOODLE_PY: ${{ github.workspace }}/nanoodle-py-sibling
+ run: node scripts/gen-llms-full.mjs --check
diff --git a/guide/graph-format.html b/guide/graph-format.html
index ee19b7b..a93dfe4 100644
--- a/guide/graph-format.html
+++ b/guide/graph-format.html
@@ -143,8 +143,11 @@ Ports and wires
Node types
"local" runs without any network call; "NanoGPT" calls the API (and spends);
- "browser-only" nodes do media processing with browser APIs — they work in the app but the
- headless executors reject them up front, before any paid call.
+ "local media" nodes process media on your own machine and never spend. The browser app
+ does that work with canvas, Web Audio and MediaRecorder. The headless executors run the
+ same 6 node types, but some of them need ffmpeg and ffprobe on
+ PATH — see what runs where
+ for the per-node detail.
| type | node | runs | inputs | outputs | key fields |
text | Text | local | — | text | text |
@@ -157,20 +160,20 @@ Node types
image | Image | NanoGPT | — | image | model, prompt, size, variations, seed, customCivitaiAir, negativePrompt |
edit | Edit | NanoGPT | image, image2… | image | model, prompt, size, seed |
inpaint | Inpaint | NanoGPT | image, mask | image | model, prompt, size, seed |
- resize | Resize / crop | browser-only | image | image | mode, width, height |
+ resize | Resize / crop | local media | image | image | mode, width, height |
vision | Vision | NanoGPT | image | text | model, q |
tvideo | Text→Video | NanoGPT | ref1… (image) | video | model, prompt, duration, aspect, resolution, modelOpts |
ivideo | Image→Video | NanoGPT | image, endframe | video | model, prompt, duration, aspect, resolution, modelOpts |
vedit | Video edit | NanoGPT | video | video | model, prompt, resolution, modelOpts |
- vframes | Video → frames | browser-only | video | frame1…frameN (image) | frames, gap, dir |
- combine | Combine videos | browser-only | clip1… (video) | video | dedup |
- soundtrack | Soundtrack | browser-only | video, audio | video | loop |
+ vframes | Video → frames | local media | video | frame1…frameN (image) | frames, gap, dir |
+ combine | Combine videos | local media | clip1… (video) | video | dedup |
+ soundtrack | Soundtrack | local media | video, audio | video | loop |
lipsync | Avatar / lipsync | NanoGPT | image, audio | video | model, prompt, resolution, modelOpts |
music | Music | NanoGPT | — | audio | model, prompt, lyrics, instrumental, duration, negative_prompt, seed, extraJson |
remix | Remix audio | NanoGPT | audio | audio | model, prompt, lyrics, duration, extraJson |
tts | Speech | NanoGPT | — | audio | model, prompt, voice, speed, instructions, extraJson |
- trim | Trim audio | browser-only | audio | audio | start, length |
- extractaudio | Extract audio | browser-only | video | audio | start, length |
+ trim | Trim audio | local media | audio | audio | start, length |
+ extractaudio | Extract audio | local media | video | audio | start, length |
transcribe | Transcribe | NanoGPT | audio | text | model, language |
comment | Comment | local (never runs) | — | — | text, color |
diff --git a/guide/run-headless.html b/guide/run-headless.html
index e2b3540..ac307d5 100644
--- a/guide/run-headless.html
+++ b/guide/run-headless.html
@@ -130,13 +130,31 @@ Inputs, outputs, settings
inline as base64 (NanoGPT has no upload endpoint); files over ~4.4 MB (~3.5 MB
for transcription) are refused locally with a clear error before any paid call.
- What runs where
+ What runs where
Pure-logic nodes (text, uploads, choice, join, comment) run locally; generation nodes
(llm, image, edit, inpaint, vision, video, lipsync, music, remix, tts, transcribe)
- call NanoGPT. Browser-only media-processing nodes (resize, vframes, combine, soundtrack,
- trim, extractaudio) are not supported headlessly: workflows containing them load
- with a warning and fail fast at run(), before any network call. The full
- table is on the format page.
+ call NanoGPT. The 6 media-processing nodes (resize, vframes, combine, soundtrack, trim,
+ extractaudio) also run headlessly. They do their work on your machine and never spend.
+ Some of them need ffmpeg and ffprobe on PATH:
+
+ | node | JavaScript | Python |
+ resize | Pure JS for a PNG source; ffmpeg for any other format | ffmpeg |
+ trim | Pure JS for a PCM WAV source; ffmpeg for any other format | ffmpeg |
+ combine | Pure JS (lossless mp4 remux) when every clip is mp4 with matching stream parameters; ffmpeg otherwise | ffmpeg |
+ vframes | ffmpeg | ffmpeg |
+ soundtrack | ffmpeg | ffmpeg |
+ extractaudio | ffmpeg | ffmpeg |
+
+ ffmpeg is a soft dependency, not a package dependency. Install it with
+ apt install ffmpeg or brew install ffmpeg. If a node needs it
+ and it is absent, that node stops the run with a clear error that names it. The error
+ arrives when the node's turn comes, so paid nodes earlier in the graph have already
+ spent — run a media-only graph once to prove your setup before you wire a paid node in
+ front of it.
+ Only an unknown node type is refused up front: the libraries warn at load and
+ throw at run() before any network call. That is how a graph saved before
+ 2026-07-22 with the retired draw node behaves. The full node table is on the
+ format page.
No account: pay per run in Nano (x402)
Skip the API key entirely. With --pay (or a library
diff --git a/scripts/check-llms-full.mjs b/scripts/check-llms-full.mjs
new file mode 100644
index 0000000..6187e25
--- /dev/null
+++ b/scripts/check-llms-full.mjs
@@ -0,0 +1,93 @@
+#!/usr/bin/env node
+// Guard: llms-full.txt must not drift away from the sources it is generated from.
+//
+// Why this exists: llms-full.txt is the single-file reference agents fetch. It
+// carries a "do not edit by hand" header, so nobody edits it — which means the
+// only thing that keeps it true is somebody remembering to re-run
+// scripts/gen-llms-full.mjs. Nobody did, and the file kept advertising the
+// retired `draw` node after index.html and both library READMEs had dropped it.
+// An agent that reads a stale entry writes a graph that cannot run.
+//
+// This guard is OFFLINE and deterministic. It checks two things:
+//
+// 1. Prefix match. Every section built from a file in THIS repo (llms.txt,
+// README.md, index.html) is regenerated and compared byte for byte. Those
+// sections are always the leading parts of the output, so the comparison is
+// a plain startsWith.
+// 2. Node keys. Every node type named in the two libraries' supported-node
+// tables must still be registered in index.html's NODE_TYPES. That reaches
+// into the sections this script cannot regenerate offline, and it is the
+// exact check the `draw` rows would have failed.
+//
+// The library sections' full text needs the sibling repos, so the byte-exact
+// check for those runs in CI (.github/workflows/checks.yml) via
+// `gen-llms-full.mjs --check` with NANOODLE_JS / NANOODLE_PY pointed at fresh
+// checkouts.
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { localParts, joinParts, firstDiff, OUT_PATH } from "./gen-llms-full.mjs";
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const fails = [];
+
+const actual = fs.readFileSync(OUT_PATH, "utf8");
+
+// --- 1. the locally-sourced prefix -----------------------------------------
+
+const expected = joinParts(localParts()).replace(/\n$/, "");
+if (!actual.startsWith(expected + "\n\n")) {
+ fails.push(
+ "llms-full.txt no longer matches README.md / llms.txt / index.html.\n" +
+ " Regenerate it: node scripts/gen-llms-full.mjs\n" +
+ firstDiff(actual, expected + "\n"));
+}
+
+// --- 2. node keys in the libraries' supported-node tables -------------------
+
+function registeredTypes() {
+ const html = fs.readFileSync(path.join(root, "index.html"), "utf8");
+ const start = html.indexOf("const NODE_TYPES = {");
+ if (start < 0) throw new Error("NODE_TYPES not found in index.html");
+ const block = html.slice(start, html.indexOf("\n};", start));
+ const keys = [...block.matchAll(/^ {2}([a-z]+): \{/gm)].map((m) => m[1]);
+ if (!keys.length) throw new Error("parsed 0 NODE_TYPES keys from index.html");
+ return new Set(keys);
+}
+
+// "upload (image/audio/video)" -> "upload"; "inpaint*" -> "inpaint"
+const bareKey = (cell) => cell.replace(/\([^)]*\)/g, "").replace(/[*†\\`]/g, "").trim();
+
+const known = registeredTypes();
+// aliases the tables use that are not literal NODE_TYPES keys
+const ALIASES = new Set(["upload"]); // stands for upload / aupload / vupload
+
+const rows = [...actual.matchAll(/^\| *(local|local media†|NanoGPT) *\| *(.+?) *\|$/gm)];
+// 3 rows per library table, 2 libraries
+if (rows.length !== 6) {
+ fails.push(
+ `expected 6 supported-node rows in llms-full.txt (3 per library), found ${rows.length}.\n` +
+ " The libraries' README table shape changed, so this guard went blind.\n" +
+ " Update the row regex in scripts/check-llms-full.mjs.");
+} else {
+ for (const [, runs, cell] of rows) {
+ for (const raw of cell.split(",")) {
+ const key = bareKey(raw);
+ if (!key || ALIASES.has(key)) continue;
+ if (!known.has(key)) {
+ fails.push(
+ `llms-full.txt lists node type "${key}" (row "${runs}"), but index.html no longer registers it.\n` +
+ " Either the type was retired and the library README still advertises it,\n" +
+ " or llms-full.txt is stale — run: node scripts/gen-llms-full.mjs");
+ }
+ }
+ }
+}
+
+if (fails.length) {
+ console.error("✗ check-llms-full");
+ for (const f of fails) console.error(" - " + f.split("\n").join("\n "));
+ process.exit(1);
+}
+console.log(`✓ check-llms-full — locally-sourced sections match; ${rows.length} supported-node rows name only registered types`);
diff --git a/scripts/gen-llms-full.mjs b/scripts/gen-llms-full.mjs
index ddbfa59..52a4e65 100644
--- a/scripts/gen-llms-full.mjs
+++ b/scripts/gen-llms-full.mjs
@@ -13,32 +13,78 @@
// - curated sections of the nanoodle-js and nanoodle-py READMEs
// (intro, At a glance, Install, quickstarts, Supported nodes)
//
-// The two library repos are shallow-cloned into .tmp-libs/ (gitignored) on
-// first run and reused after that; delete .tmp-libs/ to force a re-fetch.
+// The two library repos are shallow-cloned into .tmp-libs/ (gitignored). Every
+// run re-fetches that clone and hard-resets it to the remote default branch: a
+// reused cache is how a regeneration silently reproduces last month's library
+// README. Set NANOODLE_JS / NANOODLE_PY to a local checkout to read that
+// instead of cloning (CI does this, so its check needs no network).
//
-// Run: node scripts/gen-llms-full.mjs (writes llms-full.txt at repo root)
+// Run: node scripts/gen-llms-full.mjs writes llms-full.txt at the repo root
+// node scripts/gen-llms-full.mjs --check regenerates and diffs, writes nothing
+// node scripts/gen-llms-full.mjs --offline reuses the cache as-is, no fetch
+//
+// scripts/check-llms-full.mjs is the offline guard the pre-commit hook runs: it
+// verifies the sections that come from files in THIS repo. The library sections
+// need the sibling repos, so CI covers those.
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
-import { fileURLToPath } from "node:url";
+import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const TMP = path.join(root, ".tmp-libs");
+export const OUT_PATH = path.join(root, "llms-full.txt");
-const LIBS = [
- { repo: "nanoodle-js", lang: "JavaScript / Node.js", install: "npm install nanoodle",
+export const LIBS = [
+ { repo: "nanoodle-js", lang: "JavaScript / Node.js", install: "npm install nanoodle", env: "NANOODLE_JS",
sections: ["At a glance", "Install", "Quickstart (library)", "Quickstart (CLI)", "Supported nodes"] },
- { repo: "nanoodle-py", lang: "Python", install: "pip install nanoodle",
+ { repo: "nanoodle-py", lang: "Python", install: "pip install nanoodle", env: "NANOODLE_PY",
sections: ["At a glance", "Install", "Quickstart (library)", "CLI", "Supported nodes"] },
];
-function clone(repo) {
+// Where to read a library README from. A local checkout wins (CI points these at
+// freshly checked-out siblings); otherwise the .tmp-libs clone, always refreshed.
+export function libSource(lib, { offline = false } = {}) {
+ const local = process.env[lib.env];
+ if (local) {
+ if (!fs.existsSync(path.join(local, "README.md"))) {
+ throw new Error(`${lib.env}=${local} has no README.md`);
+ }
+ return local;
+ }
+ return clone(lib.repo, { offline });
+}
+
+const git = (args) => execFileSync("git", args, { stdio: ["ignore", "ignore", "inherit"] });
+
+function clone(repo, { offline = false } = {}) {
const dest = path.join(TMP, repo);
- if (fs.existsSync(path.join(dest, "README.md"))) return dest;
+ const url = `https://github.com/nanoodlecom/${repo}.git`;
+ if (fs.existsSync(path.join(dest, ".git"))) {
+ if (offline) {
+ console.warn(`⚠ --offline: reusing ${path.relative(root, dest)} without a fetch — it may be stale`);
+ return dest;
+ }
+ // Refresh in place. Never trust the cache: a stale README here is invisible
+ // in the output, and that is exactly how llms-full.txt went stale before.
+ try {
+ git(["-C", dest, "fetch", "--depth", "1", "origin", "HEAD"]);
+ git(["-C", dest, "reset", "--hard", "FETCH_HEAD"]);
+ git(["-C", dest, "clean", "-fdx"]);
+ return dest;
+ } catch (e) {
+ throw new Error(
+ `could not refresh the cached clone at ${path.relative(root, dest)} (${e.message.trim()}). ` +
+ `Delete .tmp-libs/ and retry, set ${repo === "nanoodle-js" ? "NANOODLE_JS" : "NANOODLE_PY"} ` +
+ `to a local checkout, or pass --offline to accept the cache as-is.`);
+ }
+ }
+ if (offline) {
+ throw new Error(`--offline: no clone at ${path.relative(root, dest)} and nothing to reuse`);
+ }
fs.mkdirSync(TMP, { recursive: true });
- execFileSync("git", ["clone", "--depth", "1", `https://github.com/nanoodlecom/${repo}.git`, dest],
- { stdio: ["ignore", "ignore", "inherit"] });
+ git(["clone", "--depth", "1", url, dest]);
return dest;
}
@@ -120,41 +166,90 @@ function nodeCatalog() {
// --- assemble ---
-const mainReadme = fs.readFileSync(path.join(root, "README.md"), "utf8");
-const llmsTxt = fs.readFileSync(path.join(root, "llms.txt"), "utf8");
-const blockquote = llmsTxt.split("\n").find((l) => l.startsWith("> "));
-if (!blockquote) throw new Error("no blockquote line in llms.txt");
-
-const parts = [];
-parts.push("# nanoodle — full reference");
-parts.push(blockquote);
-parts.push(
- "This file is generated by `scripts/gen-llms-full.mjs` in " +
- "[nanoodlecom/nanoodle](https://github.com/nanoodlecom/nanoodle) — do not edit by hand. " +
- "Every section is copied or extracted verbatim from the repository's README.md and " +
- "index.html, and from the public READMEs of " +
- "[nanoodlecom/nanoodle-js](https://github.com/nanoodlecom/nanoodle-js) and " +
- "[nanoodlecom/nanoodle-py](https://github.com/nanoodlecom/nanoodle-py). " +
- "The short index lives at [llms.txt](https://nanoodle.com/llms.txt)."
-);
-
-parts.push("## nanoodle (the site)");
-parts.push(embed(mainReadme.replace(/^# nanoodle\s*\n/, ""), "https://github.com/nanoodlecom/nanoodle"));
-
-parts.push("## Node types in the editor");
-parts.push(nodeCatalog());
-
-for (const lib of LIBS) {
- const dir = clone(lib.repo);
+/**
+ * The sections built only from files in THIS repo (llms.txt, README.md,
+ * index.html). They are always the leading parts of the output, which is what
+ * lets the offline guard compare them without cloning anything.
+ */
+export function localParts() {
+ const mainReadme = fs.readFileSync(path.join(root, "README.md"), "utf8");
+ const llmsTxt = fs.readFileSync(path.join(root, "llms.txt"), "utf8");
+ const blockquote = llmsTxt.split("\n").find((l) => l.startsWith("> "));
+ if (!blockquote) throw new Error("no blockquote line in llms.txt");
+
+ const parts = [];
+ parts.push("# nanoodle — full reference");
+ parts.push(blockquote);
+ parts.push(
+ "This file is generated by `scripts/gen-llms-full.mjs` in " +
+ "[nanoodlecom/nanoodle](https://github.com/nanoodlecom/nanoodle) — do not edit by hand. " +
+ "Every section is copied or extracted verbatim from the repository's README.md and " +
+ "index.html, and from the public READMEs of " +
+ "[nanoodlecom/nanoodle-js](https://github.com/nanoodlecom/nanoodle-js) and " +
+ "[nanoodlecom/nanoodle-py](https://github.com/nanoodlecom/nanoodle-py). " +
+ "The short index lives at [llms.txt](https://nanoodle.com/llms.txt)."
+ );
+
+ parts.push("## nanoodle (the site)");
+ parts.push(embed(mainReadme.replace(/^# nanoodle\s*\n/, ""), "https://github.com/nanoodlecom/nanoodle"));
+
+ parts.push("## Node types in the editor");
+ parts.push(nodeCatalog());
+ return parts;
+}
+
+/** The sections copied out of one library README. Needs that repo on disk. */
+export function libParts(lib, dir) {
const md = fs.readFileSync(path.join(dir, "README.md"), "utf8");
const repoUrl = `https://github.com/nanoodlecom/${lib.repo}`;
+ const parts = [];
parts.push(`## ${lib.repo} — run workflows from ${lib.lang}`);
parts.push(`Repository: ${repoUrl} · package: \`nanoodle\` (\`${lib.install}\`). ` +
`Curated from the repository README; see it for the full docs (specs, agent skills, error model, cost fields).`);
parts.push(embed(intro(md), repoUrl));
for (const s of lib.sections) parts.push(embed(section(md, s), repoUrl));
+ return parts;
}
-const out = parts.join("\n\n") + "\n";
-fs.writeFileSync(path.join(root, "llms-full.txt"), out);
-console.log(`wrote llms-full.txt (${out.length} bytes, ${out.split("\n").length} lines)`);
+export const joinParts = (parts) => parts.join("\n\n") + "\n";
+
+export function build({ offline = false } = {}) {
+ const parts = localParts();
+ for (const lib of LIBS) parts.push(...libParts(lib, libSource(lib, { offline })));
+ return joinParts(parts);
+}
+
+function main(argv) {
+ const offline = argv.includes("--offline");
+ const out = build({ offline });
+ if (argv.includes("--check")) {
+ const have = fs.existsSync(OUT_PATH) ? fs.readFileSync(OUT_PATH, "utf8") : "";
+ if (have === out) {
+ console.log("✓ llms-full.txt matches the current sources");
+ return 0;
+ }
+ console.error("✗ llms-full.txt is stale — run: node scripts/gen-llms-full.mjs");
+ console.error(firstDiff(have, out));
+ return 1;
+ }
+ fs.writeFileSync(OUT_PATH, out);
+ console.log(`wrote llms-full.txt (${out.length} bytes, ${out.split("\n").length} lines)`);
+ return 0;
+}
+
+/** Name the first line that differs, so the failure says WHAT drifted. */
+export function firstDiff(have, want) {
+ const a = have.split("\n"), b = want.split("\n");
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
+ if (a[i] !== b[i]) {
+ return ` first difference at line ${i + 1}\n` +
+ ` on disk: ${a[i] === undefined ? "" : JSON.stringify(a[i].slice(0, 160))}\n` +
+ ` generated: ${b[i] === undefined ? "" : JSON.stringify(b[i].slice(0, 160))}`;
+ }
+ }
+ return " files are identical";
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ process.exit(main(process.argv.slice(2)));
+}
From 571cedf80c20232293765af27240a5c8ac98e965 Mon Sep 17 00:00:00 2001
From: Mikkel Garcia
Date: Wed, 29 Jul 2026 18:11:32 -0600
Subject: [PATCH 3/3] Regenerate llms-full.txt for nanoodle-py 0.5.0
nanoodle-py 0.5.0 landed (#11, #12) after this branch generated the file, and it
rewrote two README sections the generator copies verbatim: "Quickstart (library)"
and "CLI". The committed file described 0.4.0, so the new CI step would have gone
red the moment this branch merged.
Regenerated with `node scripts/gen-llms-full.mjs`, which re-fetched both cached
clones first (nanoodle-py 91741de -> deea945, nanoodle-js a900ed1 -> bff73bb),
so this is the current content of both default branches, not the cache.
New content, all from the nanoodle-py README:
- the breaking input-key rename in 0.5.0, with the per-workflow key table
- optional input nodes
- run-wide `timeout=` releasing its workers, and media staying fetchable after it
- prompt length caps (UTF-16 code units, learned caps, no rewriting)
- `--json` printing the same envelope on a failed run, and on a pre-run failure
No hand edits. `node scripts/gen-llms-full.mjs --check` passes, both against
fresh clones and through the NANOODLE_JS / NANOODLE_PY paths CI uses.
Co-Authored-By: Claude Opus 5 (1M context)
---
llms-full.txt | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 88 insertions(+)
diff --git a/llms-full.txt b/llms-full.txt
index 435cdc3..79d3eae 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -428,6 +428,35 @@ Input keys are flexible (case-insensitive): the node’s custom name, `nodeId.fi
(`"n2.system"`), or the input’s label when unique. A workflow with exactly one
required input also accepts a bare value: `wf.run("hello")`.
+An input node the author marked **optional** in the editor (the checkbox, saved as
+`fields.optional`) is skippable: `spec.optional` is `True`, and a run that omits it
+proceeds with an empty value that consumers drop — an optional style reference costs
+you nothing when you leave it out.
+
+##### Breaking in 0.5.0: named nodes now name their input key
+
+A node with a custom name that surfaces exactly one input uses that name as the input key,
+even when the input is optional. This matches nanoodle-js 0.8.0, so one set of keys now
+works in both languages. It **renames advertised keys** on published workflows. Run
+`nanoodle-py inspect ` to see the current keys, or use `nodeId.field`
+(`"n2.system"`), which never changes.
+
+| workflow | 0.4.0 key | 0.5.0 key | old key still resolves? |
+|---|---|---|---|
+| pr-describe | `System prompt` | `Drafter` | no — now ambiguous across 3 nodes |
+| pr-describe | `System prompt 2` | `Auditor` | no |
+| pr-describe | `System prompt 3` | `Final PR body` | no |
+| jingle | `System prompt` | `Lyric writer` | no — now ambiguous across 2 nodes |
+| jingle | `System prompt 2` | `Style writer` | no |
+| visual-judge | `System prompt` | `Verdict` | yes — the label is unique |
+| narrated-poem | `System prompt` | `Poet` | yes |
+| video-teaser | `System prompt` | `Shot writer` | yes |
+
+Where the old label still names exactly one input it keeps resolving, so those calls need
+no change. Where two or more nodes shared it, the old key now raises `ambiguous` (or
+`unknown input` for the numbered forms) instead of silently picking one — the call fails
+loudly and costs nothing.
+
#### Media inputs
```python
@@ -453,12 +482,54 @@ result = wf.run(
)
```
+`timeout=` bounds the whole run. When it fires, `run()` returns straight away
+and the in-flight nodes stop polling within about a second, so the process is
+free to exit. Without `timeout=`, each node still waits out its own limit
+(video 600 s, audio 300 s).
+
+The deadline bounds the run, and nothing else. Media a node already produced
+stays fetchable after it: `result["Image"].save("out.png")` works once the
+deadline has passed, and works for a lane that finished while another lane
+timed out.
+
`run()` raises `RunError` when an output (sink) node fails — `error.result`
still has partial results, per-node statuses, and cost so far. Failures in
lanes no output depends on only appear in `result.errors`. Unknown/unsupported
node types, missing required inputs, bad keys, and a missing API key all fail
**before** anything is spent.
+#### Prompt length caps
+
+Many image and video models reject a prompt over a fixed character count (HTTP 400,
+`prompt_too_long`) before anything is charged. In a graph that prompt is usually written
+by an upstream LLM, so there is nothing you can shorten. nanoodle trims the prompt to the
+model's cap at a sentence boundary and tells you it did:
+
+```python
+result = wf.run({"Text": "..."}, on_progress=print)
+# {'node_id': 'n3', 'name': 'Image', 'from': 1440, 'to': 780, 'cap': 800, 'type': 'prompt-trimmed'}
+result.prompt_trims # the same records, for a caller that passed no on_progress
+```
+
+Every trim is also reported as a `RuntimeWarning`. Reporting can never fail your run: if
+you run with warnings as errors (`PYTHONWARNINGS=error`), the same sentence goes to stderr
+instead, and the run continues.
+
+A cap counts **UTF-16 code units**, which is what the model route counts and what
+nanoodle-js reports — not Python code points. One emoji is 1 code point and 2 code units,
+so `"🎉" * 450` is 450 to `len()` and 900 to the API. `from` and `to` in the trim record are
+code units for the same reason, and `nanoodle.prompt_caps.utf16_len` measures them.
+
+A cap this library does not know yet is learned from the live 400 and applied on the next
+run of the same `Workflow`. That applies to image, video and audio nodes. An `llm` or
+`vision` node is never fitted (its real limit is tokens), so its rejection is relayed
+exactly as the API worded it, with no promise that a retry would behave differently.
+
+Your own prompts are never rewritten, summarised or added to — the library only ever cuts
+an over-length prompt at the end, and always says so. `PROMPT_CAPS`, `prompt_cap`,
+`fit_prompt_text`, `is_prompt_too_long` and `prompt_cap_from_error` are public, for
+callers that do their own orchestration.
+
### CLI
Installed as `nanoodle-py` (and `python -m nanoodle` always works):
@@ -475,6 +546,23 @@ nanoodle-py inspect "https://nanoodle.com/#g=..." # a share link
- `--json` — machine-readable result
- `--env-file PATH` — load `.env`-style `KEY=VALUE` lines (existing env vars win)
+With `--json`, a **failed** run still prints the same JSON on stdout — per-node
+`status` and `error`, the outputs that did complete, the cost already spent, any
+prompt trims (`promptTrims`) and any Nano deposit the run asked for (`payments`,
+empty unless you ran `--pay`) — and exits 1. Without `--json` a failed run prints
+`error: …` on stderr and exits 1, as before.
+
+That includes a failure caught **before** the first node runs (a missing required input,
+an unknown key, an unreadable graph). Nothing executed, so `nodes` is `{}` and `costUsd`
+is `0.0`, and the reason is in `errors[0].message`:
+
+```json
+{"outputs": {"Answer": null}, "costUsd": 0.0, "costExact": true, "remainingBalance": null,
+ "nodes": {}, "errors": [{"node_id": null, "name": null,
+ "message": "missing required input: Answer"}],
+ "promptTrims": [], "payments": []}
+```
+
### Supported nodes
| runs | node types |