The noodle-graph.json format
What the editor's 💾 Save button writes, and what everything else — the app builder, the share links, the headless JS/Python executors — reads.
A saved workflow is plain JSON with no wrapper: the file is exactly the editor's serialized graph, pretty-printed. It is deliberately small and boring — nodes, links between their ports, and a little editor state you can ignore. The same file loads back into the editor, drives apps in the app builder, and re-executes from code via the nanoodle-js and nanoodle-py packages (see Run workflows headlessly).
Top-level shape
This is the starter workflow that ships with the app (text idea → LLM prompt-writer → image), verbatim:
{
"v": 1,
"nodes": [
{
"id": "n1",
"type": "text",
"x": 60,
"y": 130,
"fields": { "text": "a cozy ramen shop on a rainy night" },
"w": 220
},
{
"id": "n2",
"type": "llm",
"x": 340,
"y": 70,
"fields": {
"model": "zai-org/glm-5.2",
"system": "You write image prompts. Turn the idea into one vivid, detailed image prompt — scene, lighting, mood, style. Reply with the prompt only."
},
"w": 250,
"sizes": { "system": 120 }
},
{
"id": "n3",
"type": "image",
"x": 650,
"y": 100,
"fields": { "model": "nano-banana-2-lite", "size": "1k", "variations": "1" },
"w": 250
}
],
"links": [
{ "id": "l1", "from": { "node": "n1", "port": "text" }, "to": { "node": "n2", "port": "prompt" } },
{ "id": "l2", "from": { "node": "n2", "port": "text" }, "to": { "node": "n3", "port": "prompt" } }
],
"nid": 4,
"lid": 3,
"view": { "panX": 40, "panY": 60, "scale": 1 }
}
| key | meaning |
|---|---|
v | Format version — always 1. |
nodes | Array of node objects (below). The only key a loader strictly requires. |
links | Array of wires: { "id", "from": {"node","port"}, "to": {"node","port"} }. |
nid, lid | Next node/link id counters, so newly added nodes never collide with loaded ids. Loaders recompute them from the ids anyway. |
view | Editor camera: { "panX", "panY", "scale" }. Layout only — irrelevant to execution. |
Node objects
| key | meaning |
|---|---|
id | String id, "n1", "n2", … referenced by links. |
type | One of the node types in the table below. |
fields | The node's parameter bag — type-specific (model, prompt, size, …). Uploaded media (image/audio/video/mask fields) is stored inline as data: URLs; share links may blank these to "" (see share links). |
name | Optional user-given label. Display name resolution everywhere is: name (trimmed, if set) → the type's title → the type key. |
x, y, w, sizes | Canvas position, node width, per-textarea heights. Layout only — executors ignore them. |
Generated results are not part of the format: the serializer never persists node outputs, so a saved file contains your workflow, not your previous runs.
Ports and wires
Every port has one of four kinds — text, image,
audio, video — and only matching kinds connect. A link's
from.port names an output port on the source node; to.port names
an input on the target.
Two kinds of input ports exist:
- Declared inputs — fixed per type (e.g.
inpainttakesimageandmask), plus dynamic families that grow as you wire more:img1,img2, … (LLM/Draw vision references),image,image2, … (Edit references),ref1, … (Text→Video references),clip1, … (Combine), andendframe(Image→Video) /audio(LLM audio input). - Field overrides — every multi-line text field is also a wireable
text port whose port name equals the field name (
prompt,system,lyrics,q, …). A wire into a field port overrides the typed value at run time. Exceptions: thetext/choice/commentnodes' own fields andextraJsonare not wireable.
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.
| type | node | runs | inputs | outputs | key fields |
|---|---|---|---|---|---|
text | Text | local | — | text | text |
upload | Image input | local | — | image | image (data: URL) |
aupload | Audio input | local | — | audio | audio (data: URL) |
vupload | Video input | local | — | video | video (data: URL) |
choice | Choice | local | — | text | options, selected |
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 |
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 |
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 |
transcribe | Transcribe | NanoGPT | audio | text | model, language |
comment | Comment | local (never runs) | — | — | text, color |
Loader rules
If you write your own reader, these are the rules the editor and the headless executors already follow:
- Only
nodesis required; treatlinksand everything else as optional ({"nodes": [...], "links": [...]}is a valid minimal graph). - Legacy alias: node type
audioloads astts(older graphs 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.
- Keep a link only if both endpoint nodes exist after filtering.
- Migration: a link into a
musicorttsnode's"text"port is rewritten to"prompt".
The normative spec — including execution semantics (topological order, concurrent lanes) and the exact NanoGPT request each node produces — lives with the executors: SPEC-format.md, SPEC-engine.md and SPEC-io.md.