
Cocoon
- 1 installs
- 8 repo stars
- Updated July 4, 2026
- aengl/cocoon
cocoon is a Claude Code skill that drives a Cocoon flow-based data-processing graph as a peer client, editing cocoon.yml, running nodes, and collaborating with the human.
About
cocoon is a Claude Code skill for driving a Cocoon dataflow as a peer client alongside a human. A developer uses it inside a Cocoon project to edit cocoon.yml and node modules, inspect graph state, run and steer nodes, peek at port data, and collaborate through presence such as suggestions and callouts. Cocoon is a flow-based data-processing environment where the agent builds the graph and the human steers and monitors.
- Drive a Cocoon flow-based data-processing graph as a peer client alongside the human
- Edit cocoon.yml and node modules, inspect graph state, run and steer nodes, peek at port data
- Agent-first data mining that carries a flow from raw data to insights to running automation
Cocoon by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,803 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Jul 7, 2026 (Skillselion catalog sync)
cocoon capabilities & compatibility
- Capabilities
- dataflow build · node steering · data mining · graph inspection
- Use cases
- data analysis · orchestration
- Pricing
- Free
What cocoon says it does
Agent-first, flow-based data processing.
A collaborative data-mining environment where the agent builds the graph and the human steers and monitors
You are a **peer client** of that same core, alongside the editor
npx skills add https://github.com/aengl/cocoon --skill cocoonAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 8 |
| Last updated | July 4, 2026 |
| Repository | aengl/cocoon ↗ |
What it does
Build and steer a Cocoon flow-based data-processing graph as a peer client alongside the human.
Who is it for?
Building and steering a Cocoon dataflow graph for data mining and workflow automation.
Skip if: Projects without a cocoon.yml or a reachable cocoon serve core.
When should I use this skill?
You are in a repo with a cocoon.yml and/or a cocoon serve core is reachable.
What you get
The agent builds and runs a Cocoon flow graph while the human steers and monitors in one tool.
- Edited cocoon.yml flow and node modules
- Run and steered graph nodes with inspected port data
By the numbers
- Flow persisted as a single cocoon.yml file
- Controls render on 2 surfaces: node and window
Files
Cocoon
Agent-first, flow-based data processing. A collaborative data-mining environment where the agent builds the graph and the human steers and monitors — and the same flow carries you from raw data to insights to running workflow automation, in one tool.
You are working inside a Cocoon project: a directory containing a cocoon.yml (the flow), possibly a nodes/ dir with the node modules it references, and (typically) a running cocoon serve core that the human's browser editor is connected to. You are a peer client of that same core, alongside the editor — never a privileged observer. Connect, ask, act, disconnect; the core stays the source of truth.
Finding the Cocoon repo. This skill is installed in the user's home directory, so file paths these guides reference (examples/bgg/...,core/contract.ts,src/lib/...) are relative to the Cocoon repo root, not your cwd. Locate it once withdirname $(dirname $(readlink $(which cocoon)))— thecocoonCLI symlinks into the repo'score/cli.ts. Read the referenced files there when a guide points at one; the node source is the contract.
What the human sees
A browser canvas shows the flow as a graph of nodes coloured by status, with each edge labelled by the item count pulled through it. The canvas is read-only — edges are not drawn by hand. To change the flow, either:
- the human opens
cocoon.ymlin their own text editor and edits YAML alongside the canvas; the core watches the file and reloads with minimal disturbance, or - you (the agent) write the same file via raw
Edit/Write, and/or announce suggestions — change-sets the human applies with one click.
Each node carries a hover toolbar (run-to-here, persist, trash, …) and may carry a control — a code-declared affordance attached to the node (a steering knob, a chart, a form, an annotation UI). A control renders on two surfaces: inline on the node itself (surface: 'node' — compact) and in a detached window (surface: 'window' — roomy). You may also drop callouts — chat-friendly speech bubbles pointing at a node, stepped through by ◀ N ▶ in the header.
Vocabulary
- Flow — a dataflow graph persisted as a single
cocoon.yml. - Node — one data-processing operation. One co-located source file:
process(Node-side transform) + optionalcontrol.{data,render,event}(Node-side) + optionalhook(browser-side renderer). Plain JS/TS, no build step. - Port — a node's input/output channel. An
in:key whose value is acocoon://URI is an edge (port-to-port wiring); a purely literalin:value is config (no handle, shown as a title slice). - Edge —
cocoon://<id>/out/<port>reference, the only edge form. - Control — first-class node concept, peer to ports. Two tiers:
- steering — typed, code-declared knobs (toggle/select/text/number) rendered inline; pure pull (set →
stale→ re-pull, zero side-effects); state is an ephemeral core-held overlay, never YAML. Use for inputs that change what `process()` computes (a fetch limit, a threshold, an algorithm mode). A choice that only re-shapes already-computed data for display — grouping, metric, sort within a visualisation — is presentation, not a graph input: put it inside the free-form control as acontrol.event(re-derive, no rerun), not a steering knob that forces a full re-pull. - free-form — server-built HTML, optionally with an author-written browser hook. Split:
control.data(core-side, async, bounded) →control.render(HTML/+hook, perctx.surface='node'inline or'window'detached) →control.event(durable write +markStale; a selection is just an event). *No schema — the node is the control.* - Visualisation — a control with a render hook and no
event; a selectable one addsevent. Controls are the view layer. - Hook — the browser half of a node: an imperative
mount/update/destroyrenderer exported from the same source file.
Architecture: one core, many clients
A standalone, transport-agnostic Node core owns the runtime, the resolver, processing and all port data. The browser editor is a pure viewer (no save path, no edge-connect, no YAML pane) that loads the file itself and receives only a stream of per-node state over one WebSocket — never bulk data. You connect to the same core via the CLI, alongside the editor; reads, runs, and presence updates are simultaneous and visible in both.
A separate headless mode (cocoon run <file> --target …) owns its own throwaway Runtime and streams one port to stdout. Use it only when specifically requested.
The cocoon.yml format
There is no schema — the loader honours every key it doesn't understand (no in-app writer means nothing gets dropped on disk). Shape:
description?: 'free text'
env?: { … } # merged into process.env (under .env / .env.defaults)
nodeDirs?: ['~/my-project/nodes'] # extra node roots
nodes: # required
<NodeId>:
type: <TypeName> # required; resolved by convention (see below)
'?': 'inline docs' # always write one; also accepted as `description:`
group?: 'a/slash/path' # semantic visual cluster
persist?: true|false # serve cached output from disk
in?:
<portKey>: <edge-or-literal>
<portKey>: [<edge>, <edge>, …] # multi-edge: concat
out?:
<portKey>: ~ # statically-seeded output port- Always write a `'?'` help text. Every node you add should carry one — a self-contained statement of what this node does. Don't refer to other nodes in it (no "feeds X", "after Y", "like Z"); wiring is the graph's job, and such references rot when the flow changes.
- Node ids are the keys under
nodes:; they are the only identity references use. Renaming isEditacross the file. - `type:` resolves by convention — no registry. The core looks for
<flowdir>/nodes/<Type>.{ts,js,…}and in anynodeDirs:root (leading~/expands to$HOME/). A duplicate type name across roots is a hard error (never shadowing). - Edge vs config — the grammar's sole discriminator. An
in:value is an edge iff it matchescocoon://<id>/out/<port>exactly; anything else is a literal config value (code string, number, nested object/array), preserved verbatim and shown as a title slice on the node. There are no empty input stubs; converting config↔port is a one-line YAML edit. - Multi-edge concat.
in: { data: [cocoon://A/out/x, cocoon://B/out/y] }feeds the nodeA.x ⧺ B.y(Array.flat()depth 1). The node receives a flat list and must never re-flatten. - Comments and unknown keys are preserved on disk because nothing writes the file. Edit freely; formatting is yours.
- What is NOT in the file: persist toggle state, control state, control drafts, suggestions — all runtime overlays, ephemeral by design. The authoritative source for what `type` means is the node module file, not the YAML.
Editing the flow
Edit cocoon.yml and node modules as text, via raw Edit/Write — there is no structural API and no save path in the editor. The core watches the flow file: a save triggers a selective reload (see below). For an explicit reload after a programmatic edit, run cocoon reload.
Node module code does not need a reload at all — it is hot-swapped at execution time by the resolver when its mtime changes. This covers the node's sibling libs too: the resolver keys re-import on the newest mtime across the entry and everything it imports via relative ./ paths, so editing a shared helper hot-reloads the nodes that import it (no serve restart). The only thing that needs a serve restart is core-runtime code (the runtime itself, the resolver, the protocol).
Reload semantics
cocoon reload (and the watcher) re-parse the YAML and apply a selective diff: per node, comparing its compute signature (type, in:, static out:) plus its entire transitive upstream:
- self + upstream unchanged → preserved (output kept)
- self unchanged, upstream moved → `stale` (last output still visible)
- self changed / brand-new → reset `idle`
- removed → purged
Persisted nodes that were reset re-hydrate from disk. Editing a comment, group, ?, or any unknown pass-through key costs zero state. A nodeDirs: / env: change is a full reset — as is an edit to the flow-local .env / .env.defaults, which the core watches alongside the flow file, so a credential fix reloads on its own (no serve restart).
Execution model
Pull, not push. Nothing recomputes behind your back: you run to a node and the core processes it plus its transitive upstream in topological order, memoising completed upstream nodes. The explicitly-pulled target always re-runs (the persist-cache fast path still applies; persist is "serve cached").
Six streamed statuses — idle · queued · running · done · stale · error — the only thing the editor colours by.
- `stale` = inputs changed, result deliberately kept (the in-memory output stays visible;
processto refresh). Re-running a node ages everything reachable downstream. - Stale upstream is reused by default. "Run to here" memoises a
staleupstream like adoneone — its kept output feeds downstream,process()is not re-entered, and the consumer finishesstaletoo (a derivative-of-stale result is never shown as fresh). The cheap-iteration default: hammer a downstream node without paying for an expensive upstream chain. To force a recompute, pull the upstream directly (the target always recomputes) or pass--rerun-stale(editor shift-click). So a new value fromset-control/control-eventtakes effect only when you pull that node — pulling its downstream reuses the pre-change output. - Errors block downstream. A failed node surfaces as
error; its dependents becomeerror "Blocked — upstream X failed". Independent branches still run. - Three result-clearing semantics: persist toggle off deletes the on-disk cache only (live result +
donestay); trash drops output + cache →idle; stale is the automatic one above. - Persist is a runtime override, never YAML. Resets on
serverestart.
Talking to the core: the CLI
Requires a running cocoon serve <file> [--port N]. Default target is ws://localhost:22242; override with --core <ws-url|host:port|port> or COCOON_CORE. Exit codes: 0 ok · 1 query failed · 2 no core reachable. Invoke as cocoon … from anywhere — the global CLI is the supported entry point. (pnpm core … is only available when the cwd is the cocoon repo itself.)
# Read (does not change state)
cocoon query overview # status, counts, loadErrors, type histogram
cocoon query node <id> # status, error/errorStack/errorAt, inputDigest,
# modulePath, controls/controlState, controlData,
# logCount + logTail (newest 3 ctx.debug lines)
cocoon query logs <id> [--limit N] # buffered ctx.debug() + control-hook errors ([hook])
cocoon query upstream <id> [--depth N]
cocoon query downstream <id> [--depth N]
cocoon query peek <cocoon://id/out/port> [--descend FIELD]
[--where 'x => …'] [--select a,b,c] [--limit N]
[--expand F[,F2,…]] # iterate these fields in `sample` rows
# (one level deep, capped at 50 elements)
cocoon presence # other clients' open controls / drafts / selection
# Act
cocoon process <node> [--rerun-stale] # run on the LIVE session; blocks until settled.
# Default: stale upstream is reused (target may
# finish `stale`). --rerun-stale forces every
# stale upstream to recompute first.
cocoon cancel <node> # stop a running node; lands `error: Cancelled`,
# output dropped, downstream blocks. No-op if idle.
cocoon set-control <id> <key> <value> # steer a declared knob; pure pull (node → stale)
cocoon control-event <node> <event> # fire a declared control.event headlessly (write half);
[--json '<payload>'] # staleness is the handler's call (see Free-form controls)
cocoon refresh-control <node> # re-derive a free-form control, no pull (read half;
# = control-event <node> $mount). After a direct file write.
cocoon reload # re-read the flow file after a YAML edit
cocoon switch <file> # re-point the running core at another flow
# (file or dir). Fresh Runtime; old session state dropped.
cocoon suggest <node> <field> <value> # propose a control edit; BLOCKS for Apply/Discard
[--json '<ChangeSet|edits[]>'] [--label NAME] [--note TEXT] [--timeout MS]
cocoon callout <node> "<message>" # drop a chat-friendly POINTER (labels C1, C2, …)
[--id ID] [--tone info|warn|error] [--from NAME]
cocoon callout-clear <id-or-label> # dismiss your own callout
cocoon errors # subscribe to the error stream over WS; one batch
# per fresh failure — node errors + control-hook errors.
# Long-lived — designed for a Monitor.All output is bounded. Even peek returns a per-key schema + a small sample, not the rows; size tracks the schema, never the row count. A 153k-row port never crosses the wire. Arrays inside sample cells are shape-collapsed by default (‹array [{title,year,…}] ×4›); name the field in --expand to iterate it instead — single-level descent, 50-element cap, schema example stays bounded. Use it when a candidate row carries short structured arrays (exemplars, top, …) and you want the actual values, not the shape.
`ctx.debug()` is captured per node, not lost to stdout. Each node buffers its most recent run's ctx.debug() lines (control data/event debug too, plus browser control-hook errors — a hook's mount/update/destroy throw — tagged [hook]). query node shows the newest 3 inline (logTail) plus the total logCount; query logs <id> returns the full bounded buffer (newest 500, --limit N for fewer). overview shows only the aggregate logLines count. The buffer is ephemeral — it resets when the node re-runs and is gone on restart. This is where a node's own progress/diagnostic prints surface; for a failure, error/errorStack on query node is usually enough, then reach for query logs when the node logged its way to the bug.
`modulePath` is your way into a node. Returned by query node, it's the absolute path of the file backing the node's type. Read it — the source IS the documentation (the YAML is wiring only). Reading the code is the primary way to learn how to work with an interactive node, so its docs live in comments: the code already shows what and how, while the why and suggested usage belong in a compact top-of-file comment — read that first. It is also the only way to learn a free-form control's field names: they are HTML name attributes inside control.render, which you never see rendered.
*`cocoon switch <file>` re-points the running core at a different flow* — what the human does by clicking the header path and picking a recent. Fresh Runtime, every client repaints, the watcher follows; all old-flow session state (persist/control overlays, results) is dropped. Missing/unparseable file → hard no-op (exit 1), current flow untouched. Recents live at ~/.cocoon/recents.json.
`set-control` and `reload` mark `stale` but run nothing — `process` to apply. Pick up the new value by processing the changed node itself (the target always recomputes); processing a downstream reuses the stale output instead. set-control JIT-resolves the module, so a just-edited schema is honoured without a prior pull; a write the schema rejects (unknown key, wrong kind/range, or unknown control) is a silent no-op surfaced as IGNORED (exit 0; an unknown node is exit 1).
Free-form controls over the CLI — two halves. A free-form control's control.data is a live projection (re-read from its durable file every derive); re-deriving it re-streams controlData/HTML to every client with no `process()`, no `stale`, no status change — pure presentation, still pull-only.
- `control-event <node> <event> --json '<payload>'` — write half. Fires one of the node's declared
control.eventhandlers exactly as a UI click does (control.event(ctx, {event, payload}), then re-derive). Prefer it over a hand-rolledWrite/Editwhenever the node declares the operation: you reuse its validated handler (with ports/context) instead of rebuilding the on-disk shape, and a single event can't clobber a human edit landed since your last read. No new capability — only events the node handles (ReadmodulePathfor names + payload shapes). Staleness is the handler's call: one that runsctx.markStale()(e.g.merge_done) ages the node + downstream → thenprocessto fold; one that doesn't (cell_edit,seed_rows) stays pure presentation. - `refresh-control <node>` — read half. Sugar for the reserved
$mountevent (skips the handler, just re-derives). Fire it after writing the node's OWN durable file directly, so the human watches the table fill in real time. No-op on a node with no free-form control.
Use process instead when a write must flow downstream — that's a graph change, not a view refresh.
`process` and `suggest` resolve on a value, not a message count. process waits for the streamed status to settle terminal; suggest waits for the peer presence echo of your ChangeSet.id. Both can block indefinitely — use --timeout on suggest if the human may be away. For a long-running process, fire it with Bash(cocoon process X, run_in_background: true) — the harness notifies you on completion, no monitor verb needed. To abort one mid-flight (a crawl you no longer want, a runaway fetch), cocoon cancel <node>: it's cooperative — the run's ctx.signal aborts and the core stops driving the generator at its next yield/breathe, so it lands within a tick or two as error: "Cancelled" with its output dropped (downstream blocks like any failure; re-process to clear). A node that isn't running is a no-op. Any blocked process puller of that node unblocks too.
Collaborating with the human
Presence is an optional, orthogonal side-channel. Each connected client (editor tab, agent) may announce an opaque blob; the core relays it and interprets nothing. Nothing in processing depends on it. Empty presence is normal — it doesn't mean broken.
Three primitives, each with its own semantics:
- Suggestion (
cocoon suggest) — the human↔AI write path. You read the human's unsaved control text from presence (controlDrafts[node][field], never scraped from HTML), do the work, and announce a change-set as your own presence. The editor surfaces it as one toast; Apply only injects the value into the still-unsaved field — durability is the human's own Save afterwards. The verdict rides back in the editor's presence;suggestblocks until you getapplied/discarded/stale(the surface moved on; self-invalidated).
- Callout (
cocoon callout) — a chat-friendly pointer at a node, not a CTA. Use it to give your chat conversation a handle: "at C2 — should we drop itsview:?". Fire-and-forget: the editor snapshots callouts on first observation, so the marker survives your disconnect. The human's reply belongs in chat, not the editor. Close the loop withcallout-clearwhen the flagged work is done.
- Reading presence (
cocoon presence) — see every other client's blob: open controls (openControls), unsaved drafts (controlDrafts), node selection (selectedNodes— single click or shift-drag rectangle), viewport, label. The mirror of your callouts: agent → human iscallout, human → agent isselectedNodes.
Rules:
- Presence is connection-keyed and evaporates on disconnect. (One-shot
suggestholds its socket open by design until the verdict arrives.) - Presence is never a data path.
controlDraftsis the human's UI text; don't gate processing on it; don't treat it as a port. - Free-form controls have no schema. The node is the contract. To know which fields exist, Read
modulePath. Inventing a field name Applies into nothing. - An empty `controlDrafts` is not a blocker. "Help me fill out this form" with an empty draft is the same loop as "translate what I pasted" with a full one — just no input text to transform.
controlData(inquery node) holds the bounded slice the human is currently looking at; the "which row is shown" answer almost always lives there.
How the human refers to things
The human might not use the terms above. Map their words; but use the correct terminology in your reply.
- "the flow" / "the graph" — the
cocoon.yml+ its live core session - "a node" / "this node" / "the X node" — a node id (look at
query overviewif unsure) - "the form" / "the dialog" / "this control" — the free-form control on the focused node
- "what I have open" / "the thing I'm working on" —
presence→ first peer'sopenControls;controlDraftsfor its content - "these nodes" / "the selection" —
presence→ first peer'sselectedNodes[] - "this field" / "the X field" — one form-field
nameinsidecontrol.render— readmodulePathto learn the names - "what I typed" / "my draft" / "what I pasted" —
presence[…].controlDrafts[node][field]verbatim - "a knob" / "a setting" / "the toggle" — a code-declared steering control — read via
query node, write viaset-control - "run it" / "recompute" / "refresh the data" —
cocoon process <node>on the live session (a graph change; flows downstream) - "refresh the table/view" / "update what I'm looking at" —
cocoon refresh-control <node>after you wrote the node's file (a view re-derive; no pull) - "add these rows" / "mark this done" / "edit that cell" / "commit the merge" — a node-declared
control.event→cocoon control-event <node> <event> --json '…'(ReadmodulePathfor the event names; prefer over a raw file write when the node already handles it) - "suggest" / "draft this" / "help me fill out" —
cocoon suggest→ one Apply/Discard toast - "flag this" / "point at X" / "highlight X" —
cocoon callout <node> "<message>"— labelsC1,C2, …
Interaction rules
- Bare invocation: assume flow work, then clarify. Default the intent to "the human wants to work on a
cocoon.yml", but ask which file (and whether to create or resume) before acting. - Bootstrap eagerly. New flow: write a minimal
cocoon.yml(one node) and startcocoon serveas soon as the first node exists, so the human can follow along on the canvas. - Resume eagerly. Existing flow: run
cocoon serve <file> &first thing — no pre-check. If a core is already serving the same file, the new invocation auto-attaches and exits 0 (prints the URL). - Open the canvas, once. On the first
serveof a session,open <localhost url>so the human gets a tab. Don't reopen on subsequent restarts — the existing tab reconnects on its own. - Watch errors proactively. Arm a
Monitoroncocoon errorsimmediately afterserve. The verb subscribes to the live core's failure stream over WS and prints one batch per transition into error state:node "<id>" failed+ the real stack. Works whether you launched the core or attached to a human-started one. Each batch is usually enough to diagnose withoutquery node; fall back toquery nodeonly when the stack can't name the bug. Browser control-hook errors ride the same stream — taggednode "<id>" control error (hook). They're a crashed visualisation, not a failed node: status is untouched and downstream isn't blocked, so the data may be fine — reach forquery logs <id>(the[hook]lines carry the full stack) rather thanquery node.
Rules to know before acting
- The flow file is the wiring; the modules are the flow. YAML edits go on
cocoon.yml. Behaviour edits go on the node module file (Readit first;modulePathfromquery nodeis the path). Both are picked up live. - All graph state-changes are pull-driven. Edits,
set-control, andreloadonly markstale; nothing runs withoutprocess. - The connect handshake replays everything (
hellowith yourclientId+graph+ per-node state + presence) before anything you ask. The CLI handles this; a custom client must attach its listener before opening the socket. - A loadError on a node module is a common silent blocker. Check
query overview→loadErrorsfirst when a node won't run. - `inputDigest` is the high-value debug field. A
nodequery at error time shows the bounded shape of whatprocess()was actually fed — almost always names the bug.errorAt(nodes usingtrackedMap/trackedFilter) pinpoints the exact offending row. - Don't HTML-scrape what the human sees.
controlDraftsis the only reliable source for current control values,modulePathfor the schema.
Writing new nodes
When the task is authoring a node module (rather than driving an existing flow), read `writing-nodes.md` — a companion guide covering: the process contract, steering controls, free-form controls (data/render/event), the browser hook, the default dark-theme styles CocoonNode already ships, the project palette, design considerations for the two surfaces, and a minimal scaffold to start from.
When the node carries a chart, also read `writing-charts.md` — a survey of the eight embeddable libraries (ECharts, Observable Plot, D3, Vega-Lite, Plotly, uPlot, Chart.js, Cytoscape), a decision matrix for picking one by the constraint that hurts most, per-library notes on chart types / perf / brushing / CDN pin / gotchas, and a verified WebFetch-friendly URL per library for getting the full chart-type catalogue. Each library has a runnable standout demo in examples/charts/nodes/.
Writing charts (and picking the right library)
Companion to SKILL.md and writing-nodes.md. Read those first — this assumes you already know what a control + hook looks like, the symmetric-import rule, and how to locate the Cocoon repo (SKILL.md's "Finding the Cocoon repo" note). The reference impls behind every claim here live inside that repo at examples/charts/ (one node per library) and examples/bgg/nodes/DeltaScatter.ts / examples/tmdb/nodes/ParallelCoordinates.ts (the in-the-wild ECharts examples).
A chart in Cocoon is just a render-only control with an export const hook. There is no chart framework, no "viz subsystem", no registry — control.data bounds the payload, control.render returns a <div data-cocoon-hook="…"> placeholder, and the browser hook lazy-imports a charting library from a CDN and mounts into the div. Picking a library is therefore the only interesting decision. This file is the picker.
1 — The integration contract every chart node honours
Every chart-bearing node looks the same shape on disk; the library differs only inside mount():
// nodes/MyChart.ts
import type { CocoonProcessNode, ControlHook } from '<core>/contract.ts';
interface ChartData { ready: boolean; /* … bounded payload … */ }
export const MyChart: CocoonProcessNode = {
category: 'Charts',
description: 'One line for the inspector.',
// optional steering: dimension/colour/binning that changes the EMITTED data
controls: { /* … */ },
async *process(ctx) { /* derive bounded points + write to ports */ },
control: {
window: { width: 720, height: 560 }, // initial detached size
data(ctx) { return ctx.output.chart as ChartData ?? FALLBACK; },
render(ctx) {
if (ctx.surface === 'node') return COMPACT_HTML;
return `${STYLE}<div class="my"><div class="plot" data-cocoon-hook="MyChart"></div></div>`;
},
},
};
export const hook: ControlHook<ChartData> = {
mount(el, props) {
const root = document.createElement('div');
root.style.cssText = 'width:100%;height:100%;min-height:340px;';
el.appendChild(root);
let data = props.data;
let chart: { setOption?(o: unknown): void; resize?(): void; destroy?(): void; dispose?(): void } | undefined;
let lib: any;
const draw = () => { if (!lib || !data?.ready) return; /* lib-specific draw */ };
const ro = new ResizeObserver(() => chart?.resize?.());
ro.observe(root);
import('https://esm.sh/<library>@<pin>')
.then(m => { lib = m; draw(); })
.catch(err => { root.innerHTML = `<pre style="color:#f97373;padding:12px;">${String(err)}</pre>`; });
return {
update(next) { data = next.data; draw(); },
destroy() { ro.disconnect(); chart?.dispose?.(); chart?.destroy?.(); root.remove(); },
};
},
};Five things the chart node owes the platform:
1. Lazy CDN import inside `mount()`. Top-level import type only — the symmetric-import rule. esbuild bundles mount for the browser, so a top-level bare specifier breaks the bundle. 2. Bounded `control.data` payload. A chart never sees the raw 150k-row port. Cap with a constant (MAX_POINTS, STRIDE, BUCKETS) and pre-aggregate / sample in process or data. The payload streams over WebSocket every event. 3. `ResizeObserver` self-sizing. The shim does not feed resize events back. Observe the root; call the library's resize/redraw. 4. `destroy()` actually tears down. Disconnect the observer, call the library's dispose/destroy, remove DOM. Hooks are mounted and unmounted; a leaked instance leaks memory in the editor for the session. 5. Compact AND window branches. The inline node surface is ~240×140; almost no chart looks right there. The compact surface should be a one-line summary + "Open ▸"; the window holds the actual chart.
2 — Decision matrix (read this first)
Pick by the constraint that hurts most. ECharts is the default; deviate only when you need something it can't do well.
| Need | Library | Why |
|---|---|---|
| Just plot something — any common chart, dark theme, mouse-rich | ECharts | Largest chart catalogue, best dark theme, decent perf to ~50k points |
| Faceted small multiples / exploratory grid | Observable Plot | Plot's fx/fy + marks is the cleanest grid-of-plots API anywhere |
| A truly custom layout (chord, beeswarm, sankey w/ tweaks, radial trees) | D3 | No abstraction tax; you're writing the layout |
| Linked-brush crossfilter / declarative interaction grammar | Vega-Lite | Selections + parameters + interval brushes are first-class JSON |
| 3D surface / contour / scientific | Plotly.js | Only one of these with good 3D out of the box |
| Millions of timeseries points at 60fps | uPlot | Canvas, ~10kB, beats everything for raw point throughput |
| Embarrassingly simple: pie / bar / line / radar | Chart.js | Smallest API surface; bigger team won't bikeshed |
| Network graph / force layout / clusters | Cytoscape.js | The standard outside academia; has layouts D3-force lacks |
| Geo / hex heatmap / >1M points on a map | deck.gl | WebGL; ECharts geo tops out way before this |
| 3D scene / particles / non-chart 3D | Three.js | Not a chart lib, but covers the gap |
| HTML/SVG-native, hand-built | (no library) | A <table>, <svg>, or <div> grid IS valid. Don't reach for a lib if the chart is 30 lines of SVG. |
Heuristics that override the table:
- A chart with steering inputs that affect what's plotted → the knob is a steering control on
process, not a chart-library setting. Cf.examples/bgg/nodes/DeltaScatter.ts.controls.dimension— the chart's x-axis IS the steering output. - Brushing back into the pipeline → does NOT need a brush-native library. Any library that fires a selection callback works. The Cocoon model is: the brush state lives in the node (module-scoped Map, see
examples/tmdb/nodes/ParallelCoordinates.ts),control.eventwrites it +markStale,processreads it on next pull to emit aselectedport. Vega-Lite is a nice fit because its brush state is already JSON, but it isn't required. - A chart is a viz, not an action → no
eventhandler. The render-only pattern keeps "this is a view of upstream data" obvious to readers. - If you ever need TWO libraries in one node → split the node. Cocoon's whole shape rewards one-job-per-node; a viz and a control panel are two nodes and an edge.
3 — The libraries
Each section: what it's good at · chart types · perf envelope · interactivity · brushing · CDN pin · gotchas. The "standout demo" line points at the node in examples/charts/nodes/ that exercises the library at its best.
ECharts — the default
Standout demo: examples/charts/nodes/EChartsSankey.ts (the chart type ECharts owns categorically — flow-graph layouts with hover-glow links + curved bezier edges).
- Good at: the broadest chart catalogue of any library here, in one consistent API, with a built-in dark theme that matches Cocoon's palette out of the box.
- Chart types: scatter, line, bar, pie, sunburst, sankey, treemap, heatmap, candlestick, boxplot, gauge, radar, parallel coords, graph (force/circular), tree, calendar, geo, themeRiver, funnel, pictorial. Full catalogue (static HTML cheat sheet, deep-links into
option.html): https://echarts.apache.org/en/cheat-sheet.html - Perf: canvas renderer to ~50k points smoothly, ~200k with
progressiveset. Falls over before WebGL libs but beats everything SVG-based. - Interactivity: mouse-rich by default — hover tooltips, legend toggles, axis brush (
dataZoom), animation. All optional, all configurable. - Brushing: native (
brushcomponent) — firesbrushSelectedwith array of selected indices per series. Axis-area brushing in parallel coords also native. - CDN:
import('https://esm.sh/echarts@5.4.3')— callm.init(root, 'dark'). - Gotchas: the option object can get big; favour readability over compactness (every option is well-named). Custom tooltips need an HTML formatter; remember to
esc()inputs.
Observable Plot — the declarative grammar (Mike Bostock's successor to D3 charts)
Standout demo: examples/charts/nodes/PlotFaceted.ts (Plot's killer feature is fx/fy faceting — a grid of small multiples in one declarative spec).
- Good at: "I have tidy data, give me the right chart" — short, composable, lifts off the page.
- Chart types: dot, line, area, bar, rect, cell, hexagon, contour, density, vector, text, ruleX/Y. Composable: any mark + any scale + facets. Full mark catalogue: https://observablehq.com/plot/features/marks
- Perf: SVG; budget under 5k marks for smoothness, with strategic
canvas: trueon dot/rect for higher. - Interactivity: lighter than ECharts — tooltips via the
tipmark, but no built-in zoom/pan. For interaction you compose Plot with vanilla DOM listeners or fall back to D3 / Vega-Lite. - Brushing: not built-in; you handle pointer events on the resulting SVG and re-render. If you need brushing-first, prefer Vega-Lite.
- CDN:
import('https://esm.sh/@observablehq/plot@0.6.16'). Plot uses D3 internally; the CDN bundle includes it. - Gotchas: Plot returns an SVG node; replace, don't append (
root.replaceChildren(Plot.plot({...}))). Thetipmark needs achannelsetup; the docs are essential.
D3 — the raw-layout escape hatch
Standout demo: examples/charts/nodes/D3Beeswarm.ts (one of the few common charts no high-level lib does well — non-overlapping dot clusters by group via d3.forceSimulation).
- Good at: anything the higher-level libraries can't or won't draw. The data-binding model + scale + layout primitives are still unmatched. Use when you've sketched a chart on paper and no library has it as a preset.
- Chart types: anything. Standard charts via marks-by-hand (axis, scale, line, area, arc, rect). Layouts: force, chord, treemap, partition, pack, sankey, tree, voronoi, hierarchy. Full module list + gallery: https://d3js.org/
- Perf: SVG by default. Hand-rolled canvas keeps up with 100k+ marks.
- Interactivity: raw — wire pointer events to selections by hand. The flexibility is the point.
- Brushing:
d3.brush/d3.brushX/d3.brushY— emits selection rectangles you handle. - CDN:
import('https://esm.sh/d3@7.9.0'). Tree-shake by importing sub-modules if bundle size matters in dev (d3-scale,d3-force, etc). - Gotchas: the lib is verbose by design; budget more lines than you'd spend in ECharts. Don't reach for D3 if a preset library already does what you want — the maintenance gap is real.
Vega-Lite — the interaction grammar
Standout demo: examples/charts/nodes/VegaLiteBrush.ts (declarative linked-brush crossfilter — drag-select on one chart, the others filter; Vega-Lite is the only lib where this is a JSON spec).
- Good at: declarative interaction — selections, parameters, predicates, linked views. Specs are JSON, easy to template from
control.data, easy to round-trip back viavegaEmbed's view API. - Chart types: the grammar-of-graphics standard set (point, line, area, bar, rect, tick, rule, geoshape) plus composed views (layer/concat/repeat/facet). Full mark + view catalogue: https://vega.github.io/vega-lite/docs/mark.html
- Perf: canvas renderer available; budget similar to Observable Plot (a few thousand marks).
- Interactivity: first-class: interval selections, point selections,
bind'd input widgets,param-driven everything. - Brushing: the headline feature. Selection state is JSON; expose via
view.signal('brush')aftervegaEmbed. Round-trip intocontrolEvent. - CDN:
import('https://esm.sh/vega-embed@6.26.0')— bundles vega + vega-lite. Callembed(el, spec, {actions:false, theme:'dark'}). - Gotchas: specs are JSON-only — no callbacks inside; signal handlers run after embed. Theme
'dark'is decent but not perfect; tweakconfig.backgroundand axis colours to match Cocoon. The wire bundle is heavy (~700kB gz) — fine for an opened window, not for the inline node surface.
Plotly.js — the scientific specialty
Standout demo: examples/charts/nodes/PlotlySurface.ts (3D surface plot — the one common chart no other lib here does at all well).
- Good at: 3D (surface, mesh, isosurface), statistical (violin, contour, density), large scientific catalogues (heatmap variants, parallel-coords with brushing, ternary, sunburst). Strong default tooltips and a built-in toolbar.
- Chart types: ~40 trace types incl. scatter/line/bar/box/violin/heatmap/contour/scatter3d/surface/mesh3d/cone/streamtube/parcoords/sankey/treemap/sunburst/funnel/waterfall/geo/choropleth. Full reference: https://plotly.com/javascript/reference/index/
- Perf: mixed — WebGL traces (
scattergl,scatter3d,surface) scale to 100k+ marks; SVG traces hit the same wall as Observable Plot. - Interactivity: Plotly's modebar is built-in (pan/zoom/select/lasso/reset/save-png); hover tooltips just work.
- Brushing:
plotly_selectedevent fires on box/lasso select;plotly_relayouton zoom. - CDN:
import('https://esm.sh/plotly.js-dist-min@2.35.2'). The-dist-minvariant ships only the prebuilt bundle; ~3MB unzipped. - Gotchas: bundle size is the worst here. Use Plotly when you actually need 3D / contour; for 2D scatter/line, ECharts is smaller and prettier. The dark theme needs manual layout (
paper_bgcolor,plot_bgcolor, font colours).
uPlot — the speed specialist
Standout demo: examples/charts/nodes/UPlotMillion.ts (1M-point timeseries at 60fps — uPlot's whole reason to exist).
- Good at: dense timeseries. Canvas-only, hyper-optimised, ~10kB minified. Mouse cursor, legend, scale-sync between charts, all built in.
- Chart types: line, area, bar, points — and that's it. README + feature list: https://github.com/leeoniya/uPlot
- Perf: the best on this list. ~1M points in <50ms initial draw; pan/zoom stays smooth at 60fps with strategic subsampling above 2M.
- Interactivity: crosshair cursor, drag-to-zoom (both axes), shift-drag-to-pan, double-click to reset, native legend hover. Sync cursor across multiple charts via the
syncoption — built-in. - Brushing: the cursor is the interaction; selection range available via the cursor signal.
- CDN:
import('https://esm.sh/uplot@1.6.31')plus its CSS athttps://esm.sh/uplot@1.6.31/dist/uPlot.min.css. Load the CSS via<link>injection insidemount. - Gotchas: data format is
[xs, ys, ...]columnar, NOT[{x,y},…]. Bring a transform helper. uPlot has nodispose()— callchart.destroy().
Chart.js — the simple default
Standout demo: examples/charts/nodes/ChartJsRadar.ts (radar/polar — a chart Chart.js draws cleanly that's noticeably uglier in ECharts).
- Good at: the simplest possible API for the simplest possible charts. Defaults are sensible; the tooltip/legend/animation behave without configuration. Big team, big install base, predictable.
- Chart types: line, bar, radar, polarArea, doughnut, pie, scatter, bubble. Plus mixed-type and time-scale via adapters. Full chart-types reference: https://www.chartjs.org/docs/latest/charts/line.html (the
/charts/index is a Vuepress SPA WebFetch can't read; the per-type pages render and their sidebar names every sibling — swapline.htmlforbar.html,radar.html,polar.html,doughnut.html,bubble.html,scatter.html). - Perf: canvas; comparable to ECharts up to ~20k points, falls behind above.
- Interactivity: hover tooltips and legend toggles built-in. Pan/zoom is a separate plugin.
- Brushing: not built-in — needs
chartjs-plugin-zoomfor box-select, and even then the selection callback shape is rough. If brushing matters, look elsewhere. - CDN:
import('https://esm.sh/chart.js@4.4.6/auto')—/autopre-registers every chart type so you don't have to callChart.register(...). - Gotchas: the per-dataset/per-element
borderColor/backgroundColorknobs are the only way to theme — there is no global dark theme like ECharts'. Cf. the demo for the standard zinc/violet override block.
Cytoscape.js — the graph/network specialist
Standout demo: examples/charts/nodes/CytoscapeForce.ts (force-directed network with clustered colouring and hover-highlight neighbours).
- Good at: graph topology — nodes + edges with layout algorithms, selectors, highlighting, picking. Has the largest preset-layout catalogue (force, circular, hierarchical, grid, concentric, breadthfirst, cose-bilkent, klay, fcose, dagre).
- Chart types: one — graphs. But many layouts. Full layout + style reference: https://js.cytoscape.org/ (single huge page; WebFetch with a focused prompt — "list built-in layouts" / "node style properties" / etc.)
- Perf: canvas; smooth to ~5k nodes with default force layouts, ~20k+ with
fcose/colaand headless layout (precompute positions inprocess, ship coordinates to the hook). - Interactivity: click, drag, hover, panning, zoom, tap.
cy.on('tap', 'node', …)style. Selectors ('node[?weight]') work like jQuery for graphs. - Brushing: box-select via
boxSelectionEnabled: true; firesboxselectwith selected elements. - CDN:
import('https://esm.sh/cytoscape@3.30.4'). Extension layouts (fcose, klay, cola, dagre) each have their own CDN pin and needcytoscape.use(). - Gotchas: the default
coselayout is slow and ugly above ~500 nodes — preferfcose(extension) for anything non-trivial. Layout is async; gate the initial fit onlayout.run()completion.
Honourable mentions (in the guide; not demoed in examples/charts/)
- deck.gl — WebGL massive scale (>1M points), geo overlays, hex heatmaps, scatterplot at city/continent scale. Heavier API than the rest, but the only sane pick for geographic / huge-scale data. CDN:
https://esm.sh/@deck.gl/core@9.0.31+ sub-packages. Pair withmaplibre-glfor basemap. - Three.js — not a chart library, but the gap-filler for 3D scenes, particle systems, custom 3D vis. CDN:
https://esm.sh/three@0.169.0. - Sigma.js / vis-network — alternatives to Cytoscape; lighter API, smaller catalogue. Pick Cytoscape unless their specific defaults appeal.
- AntV G2 / G2Plot — Alibaba's grammar-of-graphics stack. Excellent and popular in CN; ECharts already wins for this codebase.
- Highcharts / AG Charts — commercial license. Skip unless legal has signed off.
- *visx / Recharts / Nivo / @nivo/ / react-chartjs-2** — React-only wrappers. Cocoon hooks are vanilla DOM; these don't apply.
4 — Brushing & linking the Cocoon way
The brushing-back-into-the-pipeline pattern doesn't care which library you used. The shape is established in examples/tmdb/nodes/ParallelCoordinates.ts and works for any lib that fires a selection callback:
// Module-scoped per-node brush state. Lives in JS memory; resets on serve
// restart / node-code edit. Brush is exploratory, not durable — by design.
const BRUSH = new Map<string, Brush>();
const readBrush = (ctx: { nodeId: string }) => BRUSH.get(ctx.nodeId) ?? EMPTY;
const writeBrush = (ctx: { nodeId: string }, b: Brush) => BRUSH.set(ctx.nodeId, b);Lifecycle:
1. Hook → shim → `event`: the hook attaches a library-specific brush callback. On change, it posts via a hidden form: <form data-cocoon-event="brush"><input type="hidden" name="state" value='…json…'></form> + requestSubmit(). Or, for richer payloads, use the JS shim path window.__cocoonControl.postEvent(nodeId, 'brush', {…}) (cf. ParallelCoordinates). 2. `event` handler: parses, calls writeBrush(ctx, state), calls ctx.markStale(). The handler does NOT round-trip the graph; data() will re-derive and re-render with the new brush. 3. `data()`: reads readBrush(ctx) every cycle, filters ctx.output.movies live, returns both the live count and the committed count (last pulled). The drift between them is the "unsaved selection" signal. 4. `process()`: on next pull, reads readBrush(ctx), emits a selected port with the filtered subset. Downstream nodes consume selected as a normal edge.
A pure-viz chart (no event handler) is the render-only case — keep it that way to signal "no upstream-mutation here". The lift to brushing is adding a brush event handler + writing the module Map; the chart library's role is only "fire a callback with the selection".
5 — Performance budgets
The bottlenecks in order of how often they hurt:
1. Wire payload (`controlData`). Streams over WebSocket every event and every pull. Cap the payload, always. A const MAX = 2000 at the top of the file + a stridedSample helper is the standard pattern (cf. examples/tmdb/nodes/ParallelCoordinates.ts MAX_POINTS). Charts that display aggregations should aggregate in process/data, not ship raw rows. 2. Library bundle download. First mount triggers a CDN fetch; esm.sh is fast but Plotly is still ~1MB on the wire. Pinning helps the browser cache hit on subsequent loads. 3. Render throughput. SVG dies around 5–10k marks. Canvas (ECharts, Chart.js, uPlot) holds tens of thousands. WebGL (Plotly's *gl traces, deck.gl) goes to millions. 4. `update()` churn. Don't tear down the chart in update; do chart.setOption(newOption) / chart.setData(...) / chart.update(). The hook contract is "swap data in place"; the library should keep its DOM/canvas.
If your chart re-creates instead of updates on every controlData change, you'll see the canvas flicker. Hold the chart instance in a closure outside draw() and call the library's incremental API.
6 — Theming (dark by default)
Cocoon's palette (zinc + violet/orange/amber accents, see writing-nodes.md §6) is the baseline. Library-specific notes:
- ECharts:
echarts.init(root, 'dark')gets you 80% there; tweak only tooltip + axis if needed. - Observable Plot / Vega-Lite: pass theme config inline. Vega's
theme: 'dark'is decent; Plot needs colour overrides. - Plotly:
layout: { paper_bgcolor:'transparent', plot_bgcolor:'transparent', font:{color:'#e7e7ea'} }. - Chart.js / uPlot / D3 / Cytoscape: hand-set colours from the zinc palette. The demos in
examples/charts/carry aCOLORSconstant at the top — copy it.
Standard chart colours from the palette: violet #8b5cf6 for primary series, amber #fbbf24 for highlight/secondary, cyan #22d3ee and coral #f97373 for +/- pairs, muted #9a9aa6 for axis text, #27272a for split lines, transparent backgrounds.
7 — Anti-patterns
- Don't draw from `render`.
renderis sync and pure (no I/O, no DOM mutation). Emit a<div data-cocoon-hook="…">placeholder; the hook owns the canvas. Anything else fights the platform. - Don't ship raw data to the hook. Bound in
data(). A 50k-row port becomes a 2k stride-sampled summary before crossing the wire. If a chart legitimately needs the full thing, that's a sign the chart should be a downstream aggregation node, not a viz with a huge payload. - Don't recreate the chart on every update. Hold the instance in the closure; the library's "set data" / "set option" path is always faster than mount + dispose.
- Don't use steering controls for presentation. A "colour scheme" knob is bake-it-in, not a
controls:entry. Steering changes the emitted data (which rows, which dimension); presentation is a code constant. Cf.writing-nodes.md§2. - Don't mix two libraries in one node. Each hook does one thing. If you need a heatmap and a network, split the node and edge them.
- Don't use React/Vue/Svelte chart wrappers (visx, Recharts, Nivo, react-chartjs-2, chart.vue). The hook is vanilla DOM by contract. Use the underlying vanilla lib directly.
- Don't forget to `esc()` tooltip strings. Library tooltips often take HTML strings; user-provided names land in attributes and innerHTML.
- Don't leave the inline node surface chartless. A 240px-wide chart-of-anything looks terrible. Show a one-line summary + "Open ▸"; the chart belongs in the window surface.
- *Clone `props.data` before handing it to a mutating chart lib.* Chart.js stashes
_metaon datasets; Plotly mutates trace arrays for range caching.props.dataarrives via Svelte 5's reactive controlData store — writing through that proxy throwsstate_descriptors_fixed. Pure-read libraries (ECharts, Observable Plot, D3, Vega-Lite, uPlot, Cytoscape) are fine; for the mutating ones, doconst safe = JSON.parse(JSON.stringify(data))indraw()and feedsafeto the library. (Don't reach forstructuredClone— it can't handle Svelte's reactive proxies and throwsDataCloneError.)
8 — A minimal chart scaffold (ECharts variant — the default pick)
import type { CocoonProcessNode, ControlHook } from '<core>/contract.ts';
interface Point { x: number; y: number; label: string; }
interface ChartData { ready: boolean; points: Point[]; n: number; }
const MAX_POINTS = 2000;
export const MyScatter: CocoonProcessNode = {
category: 'Charts',
description: 'Minimal ECharts scatter (template).',
async *process(ctx) {
const { rows } = ctx.ports.read() as { rows?: Array<Record<string, unknown>> };
const data = (rows ?? []).slice(0, MAX_POINTS).map(r => ({
x: Number(r.x), y: Number(r.y), label: String(r.label ?? ''),
})).filter(p => Number.isFinite(p.x) && Number.isFinite(p.y));
ctx.ports.write({ chart: { ready: data.length > 0, points: data, n: data.length } });
return `${data.length} points`;
},
control: {
window: { width: 720, height: 540 },
data(ctx): ChartData {
return (ctx.output.chart as ChartData | undefined)
?? { ready: false, points: [], n: 0 };
},
render(ctx) {
const d = ctx.data as ChartData;
if (ctx.surface === 'node') {
return `${STYLE}<div class="my-compact">
<strong>MyScatter</strong>
<p>${d.ready ? `${d.n} points` : 'pull upstream'}</p>
<button data-cocoon-event="$open">Open ▸</button>
</div>`;
}
if (!d.ready) return `${STYLE}<div class="my"><p class="empty">pull upstream first</p></div>`;
return `${STYLE}<div class="my">
<header class="head"><h1>MyScatter</h1><p class="sub">${d.n} points</p></header>
<div class="plot" data-cocoon-hook="MyScatter"></div>
</div>`;
},
},
};
export const hook: ControlHook<ChartData> = {
mount(el, props) {
const root = document.createElement('div');
root.style.cssText = 'width:100%;height:100%;min-height:340px;';
el.appendChild(root);
let data = props.data;
let chart: { setOption(o: unknown): void; resize(): void; dispose(): void } | undefined;
let echarts: { init(el: HTMLElement, theme?: string): typeof chart } | undefined;
const draw = () => {
if (!echarts || !data?.ready) return;
if (!chart) chart = echarts.init(root, 'dark');
chart!.setOption({
backgroundColor: 'transparent',
animation: false,
grid: { left: 56, right: 24, top: 24, bottom: 44 },
tooltip: { trigger: 'item' },
xAxis: { axisLabel: { color: '#9a9aa6' }, splitLine: { lineStyle: { color: '#27272a' } } },
yAxis: { axisLabel: { color: '#9a9aa6' }, splitLine: { lineStyle: { color: '#27272a' } } },
series: [{
type: 'scatter', symbolSize: 7,
itemStyle: { color: '#fbbf24', opacity: 0.75 },
data: data.points.map(p => [p.x, p.y]),
}],
});
chart!.resize();
};
const ro = new ResizeObserver(() => chart?.resize());
ro.observe(root);
import('https://esm.sh/echarts@5.4.3').then(m => { echarts = m as typeof echarts; draw(); });
return {
update(next) { data = next.data; draw(); },
destroy() { ro.disconnect(); chart?.dispose(); root.remove(); },
};
},
};
const STYLE = `<style>
.control .my-compact { display:flex; flex-direction:column; gap:6px; }
.control .my-compact strong { font-size:12px; color:#fb923c; }
.control .my-compact p { margin:0; color:#9a9aa6; font-size:11px; }
.control .my-compact button { background:#8b5cf6; border:1px solid #8b5cf6; color:#fff; font-weight:600; padding:5px 10px; border-radius:6px; cursor:pointer; }
.control .my { display:flex; flex-direction:column; gap:10px; height:100%; min-height:380px; color:#e7e7ea; font-size:11.5px; }
.control .my .head h1 { margin:0; font-size:14px; color:#fb923c; }
.control .my .head .sub { margin:2px 0 0 0; color:#9a9aa6; font-size:11px; }
.control .my .plot { flex:1; min-height:340px; }
.control .my .empty { color:#9a9aa6; font-style:italic; padding:20px; text-align:center; }
</style>`;Swap the library import + draw() body and you have any of the eight demos in examples/charts/. Read those next.
Writing nodes (and controls)
Companion to SKILL.md. Read after the main skill — this assumes you already know the keystones (pull graph, presence vs durable I/O, the ephemeral overlay vs durable I/O split, etc.) AND that you know how to locate the Cocoon repo (SKILL.md's "Finding the Cocoon repo" note). The whole guide is grounded in real examples under examples/bgg/nodes/ and examples/tmdb/nodes/ inside that repo; when in doubt, read those files — the node source is the contract.
Where a node lives
One .ts file under one of the resolved roots:
<flowdir>/nodes/<Type>.ts(next tococoon.yml), or- a dir declared in the flow's
nodeDirs:list.
The file exports one symbol whose name equals the filename (type: in YAML resolves by convention):
// nodes/DiscoverMovies.ts
import type { CocoonProcessNode } from '<path>/core/contract.ts';
export const DiscoverMovies: CocoonProcessNode = { /* … */ };There is no index.ts re-export step, no registry. Renaming the file renames the type. A duplicate type name across roots is a hard error.
The contract in one breath
interface CocoonProcessNode {
category?: string; // free-text label (semantic only)
description?: string; // shown in the editor toolbar/inspector
controls?: Record<string, ControlSchema>; // steering knobs (inline)
control?: ControlRender; // free-form control (HTML)
process(ctx): AsyncGenerator<Progress, string | void, void>;
}Every node has process. Controls are optional and independent: a node may carry steering knobs, a free-form control, both, or neither. A node may also export const hook (browser-side renderer) — that's the third, co-located file (same module).
1 — process: the pure transform
process is an async generator. It reads inputs (literal in: params + resolved upstream ports), reads its steering controls if it declared any, computes, writes outputs, optionally yields progress, and returns a one-line summary string shown in the node footer.
The shape that recurs across every real-world node:
import { z } from 'zod';
const Inputs = z.object({ data: z.array(Row), key: z.string() });
const Knobs = z.object({ topN: z.number().int().positive() });
async *process(ctx) {
const { data, key } = ctx.ports.read(Inputs); // validated at the seam
const { topN } = ctx.controls.read(Knobs);
yield `processing ${data.length} rows…`; // optional progress
const out = data.slice(0, topN);
ctx.ports.write({ data: out });
return `${out.length}/${data.length} kept`; // shown in node footer
}Things to know:
- `ctx.ports.read(schema?)` is your inputs (literals + edges merged). A multi-edge input (
in: [cocoon://A/out/x, cocoon://B/out/y]) arrives pre-flattened withArray.flat()depth 1 — never re-flatten in the node. Pass a zod schema to get typed, runtime-validated inputs in one go: a shape mismatch throws and surfaces as the node'serror(which blocks downstream — exactly what you want). Without a schema you getRecord<string, unknown>and have to narrow yourself. - `ctx.ports.write({ … }, schema?)` is your outputs. A key written here becomes an output port and may be referenced by downstream
cocoon://edges. An optional zod schema as the second arg validates the outputs before they cross the wire — useful for guarding against drift in long-running flows. - `ctx.controls.read(schema?)` is your steering values — defaults merged with the live runtime overlay. Available only if you declared
controls:. The same schema treatment applies. - Yield progress sparingly. A yielded string shows in the node footer during run; a yielded number 0..1 drives the running animation. Don't yield on every row — emit at coarse milestones.
- Return a tight one-line summary. It becomes the node's resting status text (
"475 games · mean Δ +0.247"). This is what someone reads on the canvas without opening anything. - Throw on real failures. A thrown error becomes the node's
errorstatus and blocks downstream. Use a one-line, actionable message —examples/tmdb/nodes/EnrichMovies.tschecks forTMDB_API_KEYand throws with the URL to get one. Don'ttry { … } catch { return [] }away real problems. - Quiet failures should call `ctx.debug(…)`. A row that's just bad data (TMDB 404, parse error on one record) gets logged and dropped, not thrown.
- The symmetric-import rule (load-bearing if the same file also exports a
hook): top-level imports are limited toimport typeand relative./paths. Every npm bare specifier, everynode:*builtin, every CDN URL isawait import(…)insideprocess/control.*/hook.mount. CDN deps are pinned at the call site:
const pLimit = (await import('https://esm.sh/p-limit@5.0.0')).default;Don't freeze the UI
The core runs on one event loop, shared with the WS transport. A node that holds it synchronously freezes the whole canvas — no repaint, new clients can't even connect — until process returns. ctx.breathe(ms?) hands the loop back.
- CPU sweep (big
map/sort/parse/regex over many items) blocks atomically — chunk it and breathe:
for (let i = 0; i < rows.length; i++) {
out.push(score(rows[i]));
if (i % 5000 === 0) await ctx.breathe(); // defer past pending I/O
}- Progress trapped behind one big `await`.
yieldis the only progress channel; while the generator is parked on a long await it emits nothing, so the node looks frozen even when the loop is fine. Classic case: a worker pool behindawait Promise.all(...). Drive it with a heartbeat:
let done = 0; // each worker bumps `done`
const pool = Promise.all(lanes.map(work)).then(() => 'done');
while ((await Promise.race([pool, ctx.breathe(500)])) !== 'done')
yield `${done}/${total}`; // live progress every 500msAn indivisible sync call you don't control (a 200 MB JSON.parse) can't be chunked — split or stream the work upstream instead.
Be cancellable (ctx.signal)
A long run (a crawl, a big paginated fetch) can be stopped from the editor's stop button or cocoon cancel <node>. Cancellation is cooperative, honored at the next yield window:
- Free for any breathing node. The runtime stops driving your generator at its next
yield/await ctx.breathe()— so a node that already breathes (it must, see above) is cancellable with no extra code. Worst-case latency is one breathe interval. Put real teardown in afinallyif you hold a resource; it runs on cancel. - Wire `ctx.signal` into the I/O you `await` so an in-flight call tears down at once instead of after it completes — the difference between stopping now and stopping in 30s:
const res = await fetch(url, { signal: ctx.signal }); // aborts the request
// pg: client.query(...) then on abort call client.cancel(); child procs: child.kill()A cancelled run lands error: "Cancelled" with its output dropped (no partial fold — write a clean run or nothing) and downstream blocks like any failure; re-process to clear. ctx.signal is a standard AbortSignal.
Resolving file paths
The core does not chdir to the flow dir. Use ctx.resolvePath(...) for anything filesystem-y:
const fullPath = ctx.resolvePath(SHORTLIST_PATH); // flow-relative
const homePath = ctx.resolvePath('~/data/x.json'); // ~ expands to $HOME
const flowDir = ctx.resolvePath(); // no args ⇒ flow dirDurable side-files vs the pull graph
Many bespoke nodes own a durable side-file (annotations, shortlists, ratings). The pattern:
1. The control's event handler writes the file (the durable truth) and calls ctx.markStale(). 2. The control's data half re-reads the file every cycle so the UI stays live without re-running process. 3. process reads the same file and folds it into the output on a pull (the commit).
Don't try to stash durable state in the control's opaque blob; that's for unsaved drafts only.
2 — Steering controls (the inline knobs)
These are typed, code-declared, schema-checked, rendered inline on the node by the editor. State is an ephemeral runtime overlay (never YAML, resets on restart). Setting one is pure pull: node → stale, user re-pulls, process reads the new value via ctx.controls.read().
The whole vocabulary — four kinds, defined as ControlSchema in src/lib/protocol.ts:
{ kind: 'toggle', label?, default? } // boolean
{ kind: 'select', label?, options: string[], default? } // enum
{ kind: 'text', label?, default?, placeholder?, multiline? }
{ kind: 'number', label?, default?, min?, max?, step? }Rules:
- Steering changes data, not presentation. If a knob changes the emitted values (which rows are kept, what's binned, the dimension on the x-axis), it's a steering control on
process. If it only changes how the data is drawn (size, palette), it's not a knob — bake it in. - A knob's value only reaches `process`.
ControlContexthas nocontrols.read(). To surface a knob in a viz, route it throughprocesstoctx.output, which the control then reads. This coupling is the pull graph. - Validate at the read site. The
ControlSchemadeclaration constrains the kind + bounds, but a malicious or staleset-controlcan still arrive — pass a zod schema toctx.controls.read(Schema)and/or clamp insideprocess/data(cf.clampN()inexamples/bgg/nodes/Shortlist.ts). Zod is a core Cocoon dep, soimport { z } from 'zod'works from any node without a CDN pin.
3 — Free-form controls (control: { data, render, event })
The action tier. The node ships HTML (and optionally a browser hook), which a generic shim mounts. There is no schema — the node is the contract.
Three pure halves, all on the Node side:
control: {
window: { width: 580, height: 700 }, // optional initial window size
async data(ctx) {
// 1) DERIVE a bounded payload from inputs + ctx.output + durable files.
// Recomputed after process() AND every control event.
// Whatever you return streams as `controlData` to the agent + hook.
return { rows: rows.slice(0, MAX), summary };
},
render(ctx) {
// 2) RETURN HTML from ctx.data (+ ctx.surface).
// Pure, sync, no I/O. Inline a <style>. Branch on ctx.surface.
const d = ctx.data as Foo;
if (ctx.surface === 'node') return `${STYLE}<div>compact…</div>`;
return `${STYLE}<div>roomy…</div>`;
},
async event(ctx, ev) {
// 3) HANDLE a posted event. Write the durable file, optionally
// ctx.control.set(...) the draft, optionally ctx.markStale().
// NEVER re-run process; the core re-derives data() and re-renders.
if (ev.event === 'toggle') { /* … */ ctx.markStale(); }
},
},Render
- Two surfaces, one render. Branch on
ctx.surface === 'node'(the compact inline render — tight budget, usually a summary + an "Open ▸" button) vs'window'(roomy detached). One render fn, two outputs. - The `$open` button. A
<button data-cocoon-event="$open">opens the detached window.$-prefixed events are client-reserved — they never reach youreventhandler. - Inline your own `<style>` (see styling section below). Scope every selector under
.control .<your-root>so co-resident windows don't collide. - HTML-escape every author-provided string that lands inside attrs or text. Every real node carries a 6-line
esc()helper — copy it. - Hidden inputs are wiring, not drafts. A form's
name="id"hidden field rides along on submit; the shim skips hidden fields when collecting drafts for presence.
Events
The browser shim wires interactivity by attribute convention:
| Attribute | Trigger | Payload |
|---|---|---|
<form data-cocoon-event="X"> | form submit | every named field in the form |
<button type="submit" data-cocoon-event="X"> (inside a form) | submit | form fields + the clicked button's name/value |
<button type="button" data-cocoon-event="X"> (outside a form) | click | {} (or the enclosing form's fields if any) |
<a/div/img/… data-cocoon-event="X"> | click | enclosing form's fields, or {} |
Special events:
- `$mount` — fired by the core when a surface (inline or detached window) first appears. Your
eventhandler is skipped by default for it; if you opt in by checkingev.event === '$mount', keep the handler idempotent. The window can be reopened. - `$open` — the open-window button. Handled by the editor; never reaches your handler.
The event handler runs Node-side. It typically:
1. Writes the node's durable side-file (the truth). 2. Optionally ctx.control.set({ … }) to update an unsaved draft (e.g. a search query — see sandbox/rate/nodes/RateGames.ts for the only legit use of this). 3. ctx.markStale() if the file change should age the node downstream. A search-style event that only updates a draft does not mark stale — it's presentation, not graph state.
Data
control.data is your derivation half. It's recomputed after every event and after every process. Keep it bounded — the payload streams to the agent as controlData, and to the browser hook as props.data. A 150k rows table never crosses the wire; sample it.
- Read `ctx.output.<port>` for the "frozen pull output" — a snapshot of what
processlast wrote. This is what couples a viz to its upstream steering knobs (cf.examples/bgg/nodes/DeltaScatter.ts). - Read `ctx.ports.read(schema?)` for the live inputs (same as
process— same optional zod schema). - Read your durable file directly for the parts that should stay live between pulls (cf.
examples/bgg/nodes/Shortlist.ts,sandbox/rate/nodes/RateGames.ts). - Never cache derived state. Re-derive it every cycle from the durable truth. Every cached-derived-state bug in this model came from caching.
Window size
control: { window: { width, height } } is the initial window size. Once the user resizes, their size wins for the window's lifetime. Pick a size that fits the roomy render — examples/bgg/nodes/Shortlist.ts is 580×700 (vertical list), examples/bgg/nodes/DeltaScatter.ts is 720×560 (landscape chart).
4 — The browser hook (export const hook)
The only node code that runs in the browser. One per node module. The core esbuild-bundles only this export and serves it; the Node-side process/control is tree-shaken out.
export const hook: ControlHook<MyData> = {
mount(el, props) {
// create your DOM into `el`, draw with props.data
// load CDN deps INSIDE mount() — never at top level
let data = props.data;
return {
update(next) { data = next.data; redraw(); },
destroy() { /* tear down */ },
};
},
};When the shim sees <div data-cocoon-hook="…"></div> in your rendered HTML, it calls mount() for each match. On every subsequent controlData update without an HTML swap, the shim calls update(next) in place — the canvas/chart instance survives. An HTML swap (e.g. you wrote different markup) tears down + remounts; design so the bulk of churn rides in controlData, not the HTML.
Rules:
- Pin CDN deps at the call site.
import('https://esm.sh/foo@1.2.3')insidemount. Different nodes in the same flow can pin different versions; no coordination needed. - Make the hook self-size. Use
ResizeObserveron the container; the shim does not feed back resize events. - Tear down cleanly in `destroy()`. Disconnect observers, dispose chart instances, remove DOM. The hook will be unmounted (window close, full HTML swap).
- Defensive `min-height` on the mount root: the inline compact surface can be tiny, and a hook with no height is invisible.
- Handle "data not ready yet" inside `mount` —
controlDatamay arrive after the hook mounts, especially before the first pull. Cf. theif (!echarts || !data?.ready) returnpattern inexamples/bgg/nodes/DeltaScatter.ts.
5 — Dark theme: defaults you already have
src/lib/CocoonNode.svelte ships generic dark-theme defaults for .control content. Use them. A control with no styling already looks right.
What you get for free under :global(.control …):
| Selector | What it gives you |
|---|---|
.control | dark panel: background:#1c1c20, border-top:1px solid #27272a, padding:8px 10px |
.control form | flex column, gap:6px |
.control label | flex column, gap:3px, label text color:#c4b5fd (violet) |
.control input, .control select, .control textarea | dark input, background:#0d0d0f, border #3f3f46, focus border #8b5cf6 |
.control textarea | + monospace, vertical resize |
.control button | dark button, hover lifts to #3f3f46/#fff |
.control .row | flex row, gap:6px (use for button rows) |
.control .control-error | red error text (#fca5a5) |
.control h3 | font-size:14px; color:#f4f4f5 |
.control p | muted body text, color:#a1a1aa; font-size:10.5px |
The minimal Annotate-style form needs zero CSS. Reach for inline <style> only when you have node-specific structure (cards, tables, charts, lists, search bars, etc.).
6 — The palette (use these, not your own)
The codebase converges on Tailwind's zinc + a small accent set. Pick values from these, don't introduce new ones — every example node above is using exactly this palette.
Surfaces (darkest to lightest):
#0d0d0f— deep input background#18181b— node body#1c1c20— control panel#212128— inset card (cf.examples/bgg/nodes/Shortlist.ts.card,examples/bgg/nodes/BiasReport.ts.card)#27272a— secondary surface, header background#3f3f46— borders, hover backgrounds
Text (loudest to quietest):
#f4f4f5— h3 title#e4e4e7/#e7e7ea— body text#d4d4d8— emphasised body#a1a1aa/#9a9aa6— muted / metadata#71717a— secondary metadata / hints#52525b— disabled / placeholder
Accents:
#8b5cf6(violet) — focus, primary action, pick/picked state#c4b5fd(lavender) — label text in controls, code identifiers#a5b4fc(indigo) — table dim labels#fbbf24(amber) — highlight / warning / freshness / "look here"#fb923c(orange) — heading / brand-y#22c55e/#4ade80(green) — success / done / "on" state#f87171/#fca5a5(red) — error / negative delta#22d3ee(cyan) /#f97373(coral) — positive / negative pair in charts (seeexamples/bgg/nodes/Shortlist.ts)#93c5fd(blue) — links
Status colours (set on the node by src/lib/CocoonNode.svelte, don't redefine): queued #3b82f6 · running #f59e0b · done #22c55e · stale #eab308 · error #ef4444.
7 — Best practices
Naming. Filename = exported symbol = type: in YAML. The flow's canvas label uses the node id (the YAML key), not the type. Choose verbs for transforms (examples/bgg/nodes/ComputeDeltas.ts, examples/tmdb/nodes/EnrichMovies.ts), nouns for data sources (examples/tmdb/nodes/DiscoverMovies.ts), and what-it-shows for viz (examples/bgg/nodes/DeltaScatter.ts, examples/bgg/nodes/BiasReport.ts).
Pure transforms first. Push complexity into process where possible. A control whose data just reads ctx.output and slices is the easiest to reason about. The examples/bgg/nodes/BiasReport.ts exception (stats live in data because nothing downstream consumes them) is conscious; you should be deliberate about it too.
One node, one job. Don't conflate "fetch + map" into one node — split, wire, persist the expensive half.
Persist the expensive half. Set persist: true in YAML on nodes whose cold pull takes more than a second or two (network, file parse, heavy compute). The persist cache file lands in _cocoon_cache/ next to the flow. Persist toggle state itself is a runtime overlay, not YAML.
Errors should diagnose. "TMDB_API_KEY not set — get a free v3 key at https://… then export TMDB_API_KEY=…" beats "missing key". The error lands in query node for the agent and in the node footer for the human.
`ctx.debug` is your debug log — and it's captured, not lost. Each call is console-formatted into the node's per-run log buffer (and echoed to the core's stderr). Read it back with cocoon query logs <id> (full bounded buffer, newest 500) or see the newest 3 inline as logTail on cocoon query node <id>; overview shows only the aggregate logLines count. The buffer resets every time the node re-runs and is gone on restart — it's per-run diagnostics, not durable output. Use it for the things that should be quiet by default — dropped rows, retries, file I/O paths, batch progress. (Control data/event debug lands in the same buffer.) For the single user-facing progress line, yield instead — that's the live status, debug is the append-only log behind it.
Bound everything that streams. controlData, peek payloads, schema digests — they all cross the wire. A 150k-row port already has schema-only treatment; your control.data payload needs the same discipline. Cap with a constant at the top of the file:
const MAX_TAGS = 60;
const MAX_ROWS = 30;
const BATCH = 5;Render-only = a "View". A control with data + render and no event is exactly what the legacy view: subsystem was — keep it event-less to communicate "this is a viz, not an actionable form."
8 — Design considerations for the dark theme
Branch surfaces aggressively. The compact node surface is ~240px wide × ~80–140px tall. The window surface is hundreds of px on a side. A single render that "just works at both sizes" almost always looks bad at one of them.
The compact pattern that recurs across every real example:
<div class="<name>-compact">
<strong>${label}</strong>
<p>${one-line summary, the one number that matters}</p>
<button data-cocoon-event="$open">Open ${name} ▸</button>
</div>Heading in orange (#fb923c), summary muted, single CTA.
The window surface uses cards. The pattern: an .head block (title #fb923c, subhead muted), then one or more .card blocks (background #212128, border #303039, radius 10, padding 12–16). Each card has a uppercase tracking-wide muted <h2> label. Cf. examples/bgg/nodes/BiasReport.ts, examples/bgg/nodes/Shortlist.ts.
Tables — tabular figures. font-variant-numeric: tabular-nums on any numeric column. Right-align numbers, left-align text. Header row gets text-transform:uppercase; letter-spacing:.07em; font-weight:700; color:#9a9aa6; font-size:9.5px.
Delta sign in colour. + deltas go cyan (#22d3ee), − deltas coral (#f97373). Keep the +/− sign visible — don't strip it.
Hairlines. Internal dividers 1px solid #27272a (subtler) or #3f3f46 (stronger). Avoid heavy borders.
No CSS frameworks, no CSS-in-JS, no Tailwind in node CSS. A node ships its <style> block as a string inside the rendered HTML. Plain CSS, scoped under .control .<root>. The mount is idempotent across re-renders (CSSOM dedupes by selector text — same <style> injected again is a no-op).
Buttons.
- Primary action: violet fill, `background:#8b5cf6; border:1px solid
#8b5cf6; color:#fff; font-weight:600. Hover #7c4ddb`.
- Default: inherits the generic dark style (
#27272abackground). - Toggle-on state: violet fill (same as primary).
- Star/icon buttons in a row (e.g.
sandbox/rate/nodes/RateGames.ts): small, low padding (padding:2px 5px), tight tracking (letter-spacing:-2pxwhen icons are stars).
Inputs in tight rows. When you have an input + a button on one row, wrap them in a flex container; size the input with flex:1; min-width:0 and the button with flex:none.
Don't restate the pull model in the UI. "Run to here" is already on the node toolbar; the canvas already turns amber when stale. A terse unsynced count (✎ 3 rated since the last pull) is fine; a "commit" button or a "click to refresh" CTA is not — that's a different mental model than the rest of Cocoon.
9 — Common gotchas
- `process` doesn't see free-form control state. The opaque control blob is for drafts only; the durable file is the truth. If a knob should change the output, declare it as a steering control.
- A control event NEVER re-runs `process`. The shim never round-trips through the graph. The control stays live because
data()re-derives. - `markStale()` is NOT a re-run. It just ages the node + downstream. The user (or agent) pulls when ready.
- Don't draw a hook from `render`.
renderis sync and pure (no I/O, no DOM). Emit a<div data-cocoon-hook="…">placeholder; the hook owns the canvas. - A node that breaks at import time fails only itself. The resolver catches load errors per module. Check
query overview→loadErrorsfirst when a node "won't run". - Watch the symmetric-import rule. If you co-locate a
hookexport AND a top-levelimport {something} from 'node:fs', the bundler tries to shipnode:fsto the browser and the bundle fails. Use dynamicimport('node:fs')insideprocessinstead — seeexamples/bgg/nodes/Shortlist.tsfor theconst nodeImport = (s: string) => import(s)helper. - Multi-edge inputs come pre-flattened. Don't call
.flat()on them again.
10 — A minimal scaffold
// nodes/MyNode.ts
import { z } from 'zod';
import type { CocoonProcessNode } from '<path-to-prototype>/core/contract.ts';
const Row = z.object({ id: z.string() /* … */ });
type Row = z.infer<typeof Row>;
const Inputs = z.object({ data: z.array(Row).optional() });
const Knobs = z.object({ topN: z.number().int().min(1).max(100) });
interface ViewData {
ready: boolean;
rows: Row[];
total: number;
}
const MAX_ROWS = 30;
export const MyNode: CocoonProcessNode = {
category: 'MyDomain',
description: 'One-line description shown in the inspector.',
controls: {
topN: { kind: 'number', label: 'top N', default: 10, min: 1, max: 100 },
},
async *process(ctx) {
const { data } = ctx.ports.read(Inputs);
const { topN } = ctx.controls.read(Knobs);
const rows = (data ?? []).slice(0, topN);
ctx.ports.write({ data: rows, total: data?.length ?? 0 });
return `${rows.length} kept`;
},
control: {
window: { width: 560, height: 480 },
data(ctx): ViewData {
const rows = (ctx.output.data as Row[] | undefined) ?? [];
const total = (ctx.output.total as number | undefined) ?? 0;
return { ready: rows.length > 0, rows: rows.slice(0, MAX_ROWS), total };
},
render(ctx) {
const d = (ctx.data as ViewData) ?? { ready: false, rows: [], total: 0 };
if (ctx.surface === 'node') {
return `${STYLE}<div class="mynode-compact">
<strong>MyNode</strong>
<p>${d.total} rows</p>
<button data-cocoon-event="$open">Open ▸</button>
</div>`;
}
if (!d.ready) return `${STYLE}<div class="mynode"><p class="empty">pull upstream to load</p></div>`;
const list = d.rows.map(r => `<li>${esc(r.id)}</li>`).join('');
return `${STYLE}<div class="mynode">
<header class="head"><h1>MyNode</h1><p class="sub">${d.total} rows</p></header>
<section class="card"><ul class="entries">${list}</ul></section>
</div>`;
},
},
};
const esc = (v: unknown): string =>
String(v ?? '').replace(/[&<>"']/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
const STYLE = `<style>
.control .mynode-compact { display:flex; flex-direction:column; gap:6px; }
.control .mynode-compact strong { font-size:12px; color:#fb923c; }
.control .mynode-compact p { margin:0; color:#9a9aa6; font-size:11px; }
.control .mynode-compact button { background:#8b5cf6; border:1px solid #8b5cf6; color:#fff; font-weight:600; padding:5px 10px; border-radius:6px; cursor:pointer; }
.control .mynode-compact button:hover { background:#7c4ddb; border-color:#7c4ddb; }
.control .mynode {
--card:#212128; --line:#303039; --muted:#9a9aa6;
display:flex; flex-direction:column; gap:14px; color:#e7e7ea; font-size:11.5px;
}
.control .mynode .head h1 { margin:0; font-size:15px; color:#fb923c; }
.control .mynode .head .sub { margin:3px 0 0 0; color:var(--muted); font-size:11px; }
.control .mynode .card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:12px 14px; }
.control .mynode .entries { list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:6px; }
.control .mynode .empty { color:var(--muted); font-style:italic; padding:20px; text-align:center; margin:0; }
</style>`;Related skills
FAQ
What is a Cocoon flow?
A dataflow graph persisted as a single cocoon.yml file, made of nodes and port-to-port edges.
How does the agent relate to the human's editor?
The agent is a peer client of the same core alongside the browser editor; the core stays the source of truth.