✕

meshfox canvas format

Reference spec for .canvas.md. Also available at any time via meshfox spec.

A canvas is a Markdown outline: heading nesting is the node tree; bookkeeping (id, position, extra edges) lives in HTML comments, invisible to any normal Markdown viewer. Model comes from JSON Canvas (jsoncanvas.org); this format moves the same tree/graph-of-Markdown-nodes model into plain Markdown instead of JSON, for readable diffs and hand-editability.

File structure

  • # (H1) — exactly one per document. Content up to the first ## is the root node. Always a node, no marker needed (it's the only H1).
  • ##, ###, ... — a heading is a node only if immediately followed by a <!-- meshfox:node ... --> comment. Without one, the heading (and everything under it) is just prose inside its enclosing node — headings can be freely used for sub-structure without fragmenting the canvas. A node's parent is the nearest enclosing shallower node heading (or root) — unless overridden by parent= (below).
  • <!-- meshfox:node ... --> — right after a heading line. Turns the heading into a node and holds its bookkeeping as key="value" attributes: id, type, x, y, w, h, color, tags, parent, fold, edgeLabel, edgeLabelAt, edgeSourceSide, edgeTargetSide, edgeVia, createdAt, updatedAt (see "Timestamps" below). All optional; a bare <!-- meshfox:node --> is enough. id defaults to a slug of the heading text; only write it explicitly for a stable handle that survives renames (e.g. because an edge references it). First write-back (running a cached block, saving layout) pins the id used, so identity is stable afterward. The one exception to the slug default is the root — the document's single top-level # heading, marked or not: with no id it is always root, whatever its title, so every canvas's root is addressable the same way and retitling it never changes its id. root is reserved for it: a meshfox:node anywhere else with id="root" is an error (and a title-derived slug of root for another node becomes root-2); the root itself may still be given a different explicit id. Retitling a node whose id is derived from its title (a bare marker, not the root — nodes made by meshfox node add, the MCP tools or the web UI always get an explicit id) is refused, since it would change the id and break whatever refers to it; pin it first (meshfox node set-id). x/y are absolute document coordinates for every node except a direct child of a group node (see "Node types" below) — for a group member, x/y are relative to that group's own x/y instead (its own top-left corner), so moving the group moves every member with it without rewriting each one's stored position. A group's own x/y is an optional anchor, draggable like any other node's — but its w/h stay always derived from its members' own resolved boxes, never stored, even once it has an anchor. fold="true" or fold="false" overrides, for this one node only, whether the web UI shows it folded or expanded by default — see "Options" below for the document-wide default this overrides. Omitted (the default) means "no override": follow the document's own default. tags="a,b,c" is a comma-separated list of free-form labels — purely descriptive (no structural meaning), shown as small chips on the node in the web UI. parent="other-id" overrides the heading-nesting-implied parent — needed once a subtree is already ###### (H6, CommonMark's ceiling: headings can't nest any deeper), where a further child has nowhere left to go but another ###### heading, which plain heading nesting alone would read as a sibling rather than a child. Nesting past H6 keeps working — every deeper node just keeps writing ###### and disambiguates with parent= instead of heading depth. Written automatically ("add child" in the UI reaches for it exactly when it's needed); hand-editing it is only for restructuring an already-flattened deep subtree. edgeLabel= is arrow text for the structural edge from this node's own parent into it — the implicit nesting edge has no line of its own to carry attributes the way a meshfox:edge does (below), so it lives here instead, on the child end: "the label of the edge that points at me". Unlike a meshfox:edge, a structural edge has no color/style/arrowhead attributes of its own. edgeSourceSide/edgeTargetSide choose the departure/arrival side (left, right, top, bottom), and edgeVia="x,y;x,y" stores ordered waypoints relative to the edge's departure point. Omitting these route attributes lets the web UI route this edge automatically. edgeLabelAt="725" places its label 72.5% of the way along the rendered path; the range is 0–1000, and omission means the midpoint.
  • <!-- meshfox:edge from="other-id" ... --> — one per line, right after a node's meshfox:node line. Declares an extra incoming edge from another node, for graphs that aren't a clean nesting tree. Any number allowed, in addition to the one implicit nesting-parent edge. Besides from, an edge line accepts the same kind of optional styling attributes the web UI's on-canvas edge editor writes: label (arrow text), color (hex or a "1"–"6" preset, same palette as a node's own color), style ("solid", "dashed" — the default look when omitted — or "dotted"), arrowStart/arrowEnd ("none" or "arrow" — an edge with neither set gets an arrowhead only at arrowEnd, matching the pre-styling default), and tags (comma-separated, same convention as a node's own). sourceSide and targetSide optionally pin the corresponding endpoint to "left", "right", "top", or "bottom"; absent means automatic side selection. via="x,y;x,y" stores ordered waypoints relative to the edge's departure point for a manually adjusted route. The web UI routes between those points while keeping the endpoints attached to their nodes; removing via restores automatic routing. labelAt uses the same 0–1000 position along the rendered path as edgeLabelAt; omission places the label at the midpoint. All are omitted from the line unless explicitly set — a plain from="other-id" with nothing else is exactly the old, pre-styling form. These extra (meshfox:edge) edges use rounded orthogonal routing in the web UI, distinct from the structural nesting tree's simple right-angle routing.
  • <!-- meshfox:canvas --> — optional, first line of the file. Marks a plain *.md file as a canvas even though it isn't named *.canvas.md (used so this doubles as auto-discovery hint; not required for parsing — any correctly-structured file parses as a canvas regardless of name).

Timestamps

createdAt/updatedAt on meshfox:node are optional RFC3339 timestamps (any valid offset, e.g. 2026-08-29T10:15:00Z or 2026-08-29T13:15:00+03:00) — a malformed value is a meshfox validate error, same as an unknown type=. Both are absent by default, on every document: meshfox is first and foremost a documentation format, and automatic timestamp bookkeeping isn't something most documents want. Automatic stamping is opt-in per document, via the auto-timestamps option (see "Options" below).

  • createdAt — stamped automatically by meshfox node add (and the web UI's "add child") at creation time, for a document that declares auto-timestamps. Never rewritten afterward except by an explicit node meta --created-at (for backfilling/importing existing data with a real historical date — works on any document, auto-timestamps or not).
  • updatedAt — stamped automatically whenever a node's own body text actually changes: node body, node append, a cached block's output being written back, the web UI's in-node editor. A body write that comes out byte-identical to what was already there (re-running an unchanged cached block, say) never touches it — so re-running a chain with no real output changes doesn't manufacture a diff on its own. Never touched by a pure position/style/tag change (node meta) — only body writes bump it, so it stays a signal of "when did this node's own content last change", not "when was this node last touched on the canvas at all". There's no way to set it directly (no --updated-at flag anywhere) — it wouldn't mean much once it stopped being trustworthy.

meshfox's own automatic stamps are always UTC (Z) — a hand-typed --created-at may use any offset, but two auto-stamped values are always directly, correctly string-comparable against each other regardless. Mixing offsets (an auto-stamp and a hand-typed one with a different offset) doesn't compare correctly as plain strings — see "Constraint fences" below for a way around that (.created_at_ts/.updated_at_ts, offset-independent by construction).

A document has to declare the auto-timestamps option (see "Options" below) to get either stamp from either automatic path at all — insert_child_node writes no createdAt, and set_node_body writes no updatedAt, unless it's declared. An explicit node meta --created-at works regardless of the option either way; it's a separate, deliberate manual path, not gated by it.

Node types

type= on meshfox:node picks one of JSON Canvas's four kinds. Defaults to (and is omitted for) text.

  • text (default) — freeform Markdown body.

  • group — purely organizational; body must be empty. Children are whatever nests under it structurally (no separate containment mechanism); a direct child's own x/y is relative to the group's, not absolute — see x/y above.

  • file / link — body must start with exactly one Markdown link on its own line, [label](target), optionally followed (after a blank line) by a plain-prose caption — inline formatting (bold/italic/inline code/links) is fine, but no block-level Markdown: no headings (including the setext ===/----underline form), lists, block quotes, code fences, tables, images, thematic breaks, or raw HTML. Same-line trailing text right after the link's closing ) is still rejected — a caption has to be its own paragraph. A file node also accepts two optional display attributes on its meshfox:node line:

    • display="link" (default), display="code" or display="table" — code shows the target's own file content as a read-only, non-runnable syntax-highlighted preview instead of a plain clickable link. The file is read fresh from disk on every view, and again whenever it changes while the page is open (the worker stamps each such target's mtime and size twice a second and announces a change over /api/watch as files-changed — a block that just wrote the file, or an editor, refreshes the preview without a reload), confined to the canvas's own directory tree (plus the directories of type="dir" variables and the exact files of type="file" variables, see "Directory variables" and "File variables") — never written back.

    • display="table" — shows the target as an interactive, read-only table (CSV, TSV, Parquet, JSON lines, compressed variants, ... — anything the duckdb CLI reads), windowed so huge files stay usable. Requires the duckdb executable; see "Table previews" below.

    • csv-strict="true|false" — CSV/TSV dialect validation for a file node with display="table"; default true. See "Table previews".

    • lang="..." — syntax-highlighting language hint for display="code" (e.g. lang="rust"). Optional; when omitted, the language is guessed from the target's file extension. Ignored when display isn't code.

    • interpreter="..." — a shebang-style command, e.g. interpreter="python3 -u" (word-split the same way a #!/usr/bin/env -S ... shebang line's own arguments would be — quoting is honored), to run against target, making the node runnable: interpreter target (the target's path resolved relative to the canvas's own directory, confined to it — same boundary display="code" targets are resolved within). Optional; omitted means the node isn't runnable this way. See "Runnable file nodes" below for everything a runnable node carries (dependencies, caching, arguments, history). The web UI's "▷ run" button (next to "expand", in a runnable node's title bar) runs its block the same way it does a text node's default code block. A runnable code fence (see "Runnable code fences" below) can carry the same interpreter= attribute — this is that mechanism's origin.

      <!-- meshfox:node type="file" display="code" lang="rust" -->
      
      [main](./src/main.rs)
      
      <!-- meshfox:node type="file" interpreter="python" -->
      
      [seed data](./scripts/seed.py)
      

    link nodes don't support display/code/interpreter — their target is an external URL, not something meshfox reads from or runs off disk. A link node accepts its own single attribute instead:

    • preview="true" — fetches the target's OpenGraph metadata (title/description/image) and shows it as a card below the plain link, in both the web UI and the terminal viewer. false (the default, omitted from the file) shows just the plain link, same as before this attribute existed. Setting preview= on a non-link node is a parse error, same as an unknown type=.

      Fetched over the network on first view and cached in memory for the life of the meshfox view/meshfox tui process — not persisted, not shared across processes, and never retried within that process once a fetch has failed. Since a canvas file's link targets are often attacker-controllable (the file itself may come from an untrusted source), the fetch is hardened against SSRF: only http/https, resolved addresses that are loopback/private/link-local/etc. are rejected before ever connecting, and redirects are followed manually with the same check re-run on every hop.

      <!-- meshfox:node type="link" preview="true" -->
      
      [meshfox](https://github.com/orofarne/meshfox)
      
  • include — same link-plus-optional-caption body as file/link, but the target's own raw file content is dumped in dynamically by whatever consumer resolves includes, as this node's own body — never written back to disk. run/validate see the bare link (and caption, if any), same as file/link. See "Includes" below.

Any other type= value, a non-empty group body, or a file/link/ include body that doesn't start with a single link (or whose caption carries block-level Markdown) is a parse error (meshfox validate catches these, plus a missing include target).

There's no node type for a constraint contract — a Starlark check is an embedded fence living in any node's ordinary body, alongside its prose and runnable code, same as a ```bash name="..." fence. See "Constraint fences" below.

A file node whose target is another .canvas.md file doesn't splice that file's content in — it opens it as its own, separately-isolated canvas (own process, own crash/hang boundary), the same "click through" meshfox view/meshfox tui already use for cross-canvas navigation, with an optional #node-id fragment deep-linking to a node in it.

WebUI and TUI open file nodes through POST /api/nodes/:id/open with context=web or context=tui. The worker resolves the target and checks file access and existence; clients display API failures. For a plain file the TUI gets open_file with the checked path and opens it itself, as [tui.open] / [open] in .meshfox/config.toml say (an extension/glob key list per command, trailing & to detach, system for the OS default; most specific entry wins) and ignoring those commands on a --no-exec core. For the WebUI the worker itself starts a matching [web.open] / [open] command in the background (same environment as a block; a bad rule is a 422, a program that cannot start a 500); with no match, or on a --no-exec core, the plain file opens through the coordinator. For canvas targets the coordinator gets or starts the target worker, and the API returns its port and optional node fragment. WebUI opens a browser tab; TUI enters a nested API client and returns to the parent canvas on q, without reading the target or spawning another TUI. Opening a canvas, or a plain file without a configured command from the WebUI, requires a coordinator; an unavailable coordinator is an API error.

Includes

type="include" dynamically dumps another file's raw content into this node's own body, resolved fresh every time a consumer asks for it (e.g. the server, before serving GET /api/canvas to meshfox view) — nothing is ever written into the including file. run/validate operate on a single file's raw text and never resolve includes; only validate reaches far enough in to catch a missing target.

The target's own content is always taken verbatim — headings shifted down (clamped to H6, CommonMark's ceiling) by the include node's own level, so e.g. its top-level # doesn't read as a second document root once nested — even when the target happens to be a .canvas.md with meshfox structure of its own: an include never parses that structure, never introduces a new addressable node, and never recurses into an include marker written inside the target's own text. The include node itself becomes text, its body marked as belonging to the target file rather than the including document — not editable per-node. Source mode's file picker can edit ordinary Markdown targets. A target with a .canvas.md extension or a meshfox:canvas marker is read-only through the including canvas, including Source mode; open that canvas separately to edit it.

A file node's display="code" is the read-only-preview counterpart for a target you want to show without folding its content into this node's own body at all — see "Node types" above.

Table previews

display="table" on a file node shows its target as a read-only table: a virtualized grid in the web UI and a scrollable table in the terminal viewer, with sorting, per-column filters and a text search. Nothing is ever written back to the file — edit it with any external editor; the preview notices the change (size/mtime) and reloads.

<!-- meshfox:node type="file" display="table" -->

[sales](./data/sales.parquet)

Requires the duckdb command-line tool. meshfox never downloads or installs it. It's looked up in this order: the MESHFOX_DUCKDB environment variable, duckdb_path under [tables] in .meshfox/config.toml, PATH, then the usual install locations (~/.duckdb/cli/latest/duckdb, /opt/homebrew/bin, /usr/local/bin, /usr/bin). A path set explicitly that doesn't exist is an error naming it. When nothing is found the node shows a platform-neutral message ("DuckDB CLI not found ...") instead of a table; there is no fallback preview.

Formats. Not restricted: the reader is chosen from the file name — .parquet/.parq read as Parquet, .json/.jsonl/.ndjson as JSON, everything else as delimited text with auto-detected dialect and types (a trailing .gz/.zst is looked through). If type inference over the sample fails on a later row, the file is read again with every column as text. CSV/TSV dialect validation is strict by default. A particular table node may opt out with csv-strict="false", passed to DuckDB as strict_mode=false, for example for bank exports with mixed CRLF/LF line endings:

<!-- meshfox:node id="statement" type="file" display="table" csv-strict="false" -->

[Statement]($STATEMENT)

csv-strict accepts only true or false, is allowed only on file nodes with display="table", and applies only to that node (no inheritance or document-wide setting). It has no effect on Parquet/JSON readers or runnable blocks that import the file. There is no automatic retry in non-strict mode and no ignore_errors row skipping. The same setting is used for the initial preview, full import and text-type retry. Changing it invalidates that node's table session/cache even if the source file has not changed. The target is confined like display="code": the canvas directory and the directories of type="dir" variables plus the exact files of type="file" variables.

How it works. The worker keeps one long-lived duckdb process per table node over a cache database. Text-like files are imported once, in the background (until then the first 200 rows are read straight from the file and sorting/filtering aren't available yet); Parquet is a view over the file itself, with no copy. A sorted/filtered/searched view is materialized once and then paged cheaply. The client never sends SQL: a view is a list of sort keys, filters and a search string, whose columns are indexes checked against the table's own columns and whose values are quoted as literals. After loading, the process is cut off from the file system and network (only the source file of a Parquet view and its cache directory stay reachable) and its settings are locked. Cells longer than 10,000 characters are cut in the preview; values arrive as text, so no precision is lost to JSON.

Cache. Under <canvas dir>/.meshfox/tables/, one directory per table, named after a hash of (path, size, mtime) so a changed file re-imports and its stale entry is removed. [tables] cache_max_bytes in config (default 4 GiB) bounds the cache including DuckDB's spill files: the least recently used tables not being imported are evicted past it, and a single table that alone doesn't fit is refused with an error naming that key. In a read-only canvas the cache lives in a per-process directory under the system temp dir instead (see "Read-only canvases"); if that can't be created the table fails with an error — there is no in-memory fallback.

In the terminal viewer (meshfox tui) a table node shows its status line (1,234 rows × 5 cols, or the importing note), the header and the first ten rows inline under the node's title, cut to the pane's width. Enter on the node opens it full screen: j/k/arrows and h/l move the cursor, PgUp/PgDn and Ctrl-u/Ctrl-d page, g/G (or Home/End) jump to the first/last row, 0/$ to the first/last column; s sorts by the cursor's column (none → ascending → descending; S adds it as a further key), / searches every column, f filters the cursor's column (the same little language as the web grid: text, >10 >=10 <10 <=10, =x !=x, ^starts $ends ~contains, null, !null), F clears that column's filter, x resets sorting, filters and search, Enter shows the cell's whole value, y copies it (OSC 52, so it works over ssh), q/Esc closes. The mouse wheel scrolls (sideways wheel moves by column), a click on a header sorts by that column (Shift adds a key), a click on a cell moves the cursor. Sorting, filtering and search wait for the import to finish; a rejected view (a filter value the column can't hold) shows the worker's message under the grid.

The terminal viewer reads everything through the worker, never off disk: that goes for a display="code" node's content too (GET /api/nodes/:id/file-content, fetched in the background — the pane says "loading preview…" until it lands, and a copy older than two seconds is refetched in the background the next time the pane is drawn, so an edited file shows up). One consequence: the worker's confinement to the canvas directory now applies to display="code" in the terminal viewer as well.

Static export and PDF. meshfox static and meshfox pdf render a table node as a plain HTML grid of its first 100 rows under a caption (1,234 rows × 5 cols · first 100 shown), with number columns right-aligned and NULLs marked — the interactive grid is for the live UIs. Both fetch it through the worker, so they need duckdb too; they wait for the background import to finish (saying so on stderr). When a table can't be fetched (no duckdb, an unreadable file, a table over the cache limit, an import that doesn't finish within an hour) the command fails — exit status 1, naming each such node, its target and the reason — rather than exporting a page with a hole in it; a canvas with no table nodes never needs duckdb. display="code" in meshfox pdf is now read through the worker as meshfox static already did. See TEMPLATES.md for the markup a template can style.

API (worker, GET only, so allowed on read-only canvases):

  • /api/nodes/:id/table — {state: "importing"|"ready"|"failed", error, columns: [{index, name, type, kind}], totalRows, fileSize, mtimeMs, version, preview}. Starts the import on first call; poll while importing. A missing duckdb or an unusable cache is 200 with state: "failed" and error: {kind, message} (duckdb-missing, cache, import, limit).
  • /api/nodes/:id/table/rows?offset=&limit=&view= — one window (limit ≤ 1000) as {rows: [[string|null, ...]], matchedRows, totalRows, version, state}. view is JSON: {"sort": [{"column": 2, "desc": true}], "filters": [{"column": 0, "op": "ge", "value": "10"}], "search": "text"}. Ops: eq ne lt le gt ge contains startswith endswith isnull notnull (comparisons use DuckDB's own typing of the value against the column; text ops are case-insensitive). A value the column can't hold is a 400 with DuckDB's message.

Runnable file nodes

A file node with an interpreter= and a link target is runnable: the target file is run as interpreter target. The node carries one implicit block, named after the node's id, and otherwise behaves like a runnable fence — deps=, env=, cache, confirm, lease, defer, arguments, run history, meshfox run/list/logs, run --detach, and the same block controls in the web UI and the terminal viewer (run, run chain, history, source fold, dependencies, output). meshfox run <node> addresses it without a trailing block name. A worked example is examples/runnable-files.canvas.md.

The script runs in the canvas's own directory (for a node spliced in from an include, that include's directory) — not the script's. The target is resolved relative to that directory and confined like a display="code" target (the canvas tree plus the directories of dir/file variables).

Block attributes go on the node's own meshfox:node line, with the meaning they have in a fence's info string: deps, inputs, outputs, env, cache, confirm, default, always, autorun, output, output-attrs, render, send, lease, defer, argv. name is always the node's id, interpreter is the node's own attribute, and fold keeps its meaning as the node's fold state. They are accepted only on a file node with an interpreter; meshfox validate rejects them elsewhere. tty, service, autoclose and tangle are not supported on a file node — the script runs directly from its file — and are rejected too.

Editing. meshfox node block <node> <node> (and the API behind it) edits a file node's block attributes where they live, on the meshfox:node line: --cache/--no-cache, --always, --default, --deps/--clear-deps, --env/--clear-env, --interpreter, --lang, leaving the link, caption and cached output alone. It refuses what has no meaning there: --code-file (the code is the target file), --rename (the block is named after the node), --tty/--service/--autoclose, and --clear-interpreter (use node meta --interpreter "", which also drops the block attributes). Removing cache also drops the node's cached-output region on the next save, as it does for a fence. A drag, resize or any other node meta change keeps the block attributes.

Body. The link line, an optional plain-prose caption (as for any file node), and — written by meshfox, never by hand — one cached-output region at the very end, exactly like the one a fence's cache writes (<!-- meshfox:output name="<id>" hash="..." -->, see "Cached output"). Any other block-level Markdown, or text after the region, makes the body invalid. The region is recognized only directly after the node's link/caption, the file-node counterpart of "directly after a fence".

Arguments. meshfox:arg declarations (see "Block arguments and applications") sit immediately above the link line, with only blank lines in between — the link plays the part of the fence's opening line. Binding, types, defaults, required, applications (extract[lang=hy]) and manual forms are those of a fence. Values reach the script as environment variables, and — unless the node has argv="false" — as --name=value command-line flags after the script path, in declaration order, with canonical values (python3 extract.py --lang=hy --pages=3). Flags are passed to any block with an interpreter= (a fence's too); a plain bash fence has no script path to append them to and gets the environment only. argv="false" is for scripts that would reject unknown flags.

cache and arguments are exclusive for every block, fence or file node: the cached output belongs to the block, not to one application of it, so a block with a meshfox:arg signature and cache is a validation error.

Freshness. The block's fingerprint covers the target path and a digest of the file's content, so editing the script makes its cached output stale and its "already ran this session" record invalid, like editing a fence.

Display. The block is drawn under the link and caption. Its source — the file's current content — starts folded unless the node has display="code"; display="table" has no effect on a runnable node.

Runnable code fences

Lives inside a node's Markdown text, as fence-info-string attributes:

```bash name="build" cache
cargo build --workspace
```
  • name — identifies a fence for meshfox run/output caching; must be unique within its node. Normally required to make a fence runnable — except a node may have one fence with no name at all, which is runnable too, implicitly named after its own node id. A second unnamed fence in the same node makes the omission ambiguous, so neither gets a name (same as any unnamed fence today — not an error, just not runnable). This is what lets meshfox run/list skip a redundant trailing block-name argument that would just repeat the node's own id — see "CLI" below — and applies the same way to a fence whose explicit name happens to already match its node's id.

  • confirm — optional flag (confirm or confirm=true; confirm=false disables it), for potentially destructive operations. Before any step starts, all marked blocks in the resolved chain (including implicit variable/file producers) require explicit user approval. Web UI shows an internal confirmation modal styled like Configure; TUI requires y (Enter/Escape cancel); CLI asks [y/N] in an interactive terminal and otherwise refuses, unless meshfox run --confirm is supplied. MCP run accepts confirm: true only after obtaining user approval; without it the call returns a confirmation-required error naming the gated blocks. Approval applies to one invocation, never to subsequent runs or session cache. --no-deps excludes explicit dependencies from the gate, but retains implicit producers. Automatic runs have no approval and are refused if their chain contains a marked block. Server execution endpoints enforce the flag, including TTY, force-run and service restart; dynamic replanning checks it again before executing newly discovered steps. The flag does not affect fingerprints. For example: python name="cleanup" interpreter="$PYTHON -u" confirm.

  • cache — optional flag (cache or cache=true); opts into persisting output back into the file.

  • default — optional flag (default or default=true); marks this fence as its node's default block — the one meshfox run <path-to-node> addresses without a trailing block name (see "CLI" below). A fence whose (implicit or explicit) name already equals its own node's id counts as default too, without needing this flag. At most one block per node may be default (explicitly, implicitly, or one of each) — meshfox validate reports a conflict as an error.

  • deps — optional, comma-separated list of other blocks this one runs after. Each entry is either a bare block name (a block in the same node) or node-id/block-name (a block in another node, addressed by node id the same way meshfox:edge from= addresses nodes). Running a block always runs its full dependency chain first, automatically — each dependency's own deps are resolved transitively, in order, with no block run twice even if several blocks in the chain depend on it. A cycle, or a deps entry naming a block that doesn't exist, is a meshfox validate error. A trailing ! on an entry (deps="build, schema/migrate!") ties that dependency's own web UI/TUI session-freshness decision to this block's: whenever this block ends up running for real this pass (not skipped as "already fresh"), the ! dependency is forced to run for real too, regardless of its own fingerprint or always; when this block is itself skipped, the ! dependency is left to its own normal freshness decision instead — see always below for the plain (unconditional) alternative, and why a ! edge exists as a separate mechanism from it. A ? suffix makes the edge conditional — the condition belongs to this dependency, not to the target block, so another block may depend on the same target unconditionally. deps="fix?USE_AGENT" keeps the edge only while the bool variable (or argument of this block) USE_AGENT is true; fix?!USE_AGENT only while it is false; fix?PROVIDER=claude only while PROVIDER equals claude (any variable type, a select only accepts one of its choices), and fix?!PROVIDER=claude while it does not. The condition follows the application arguments (child[lang=$L]?FLAG) and precedes a sync ! (fix?USE_AGENT!); a $ before the name is cosmetic, as in env=. A false condition removes the edge entirely: the dependency is not run for this block and contributes nothing to its fingerprint, so flipping the value makes the block stale; the dependency still runs when another unconditional edge reaches it, and running it directly ignores the conditions on edges that point at it (its own deps= conditions still apply). The variable is resolved like any other variable the block references (a prompt or configure entry, --set, its default), and only for chains that reach the edge. A from=-computed variable first runs its source block and then decides the edge, like any other value computed during a run; before a value is known, static views (and cycle checks) treat the edge as present. A conditional entry governs the whole dependency, not just the explicit edge: while it is off, the target is also not pulled in as the producer of one of this block's inputs= (an inputs= declaration that only consumes that target's outputs= is ignored entirely — not required to exist, not part of the fingerprint) nor as the from= source of a variable exposed through env= (the variable is left out of the environment and never required). When the entry is on, it is an ordinary explicit deps= edge, so it brings the usual execution cascade. If the same dependency is also named by an unconditional entry, nothing is switched off. A variable computed by a conditionally-depended-on block can only be used through env=: interpreter=, artifact paths, application arguments and conditions need the value always, and meshfox validate rejects them. Conditions exist only in deps=: lease=, defer= and from= reject them. meshfox validate reports an empty condition (fix?), an unknown variable, a bare condition on a non-bool variable and a comparison value the variable cannot hold. --no-deps does not follow deps= edges, but a variable named in a condition is still resolved (and still required when it has no value), exactly like one bound in an application argument.

  • inputs / outputs — optional, comma-separated file paths. Inputs accept glob patterns (src/**/*.rs); outputs must name exact files. Paths are relative to the block's effective working directory and normalized before matching. $NAME / ${NAME} substitute declared variables; $$ is a literal dollar sign. These references resolve variables and from= sources even without env= (use env= separately to expose them to the process). A matching outputs= declaration automatically supplies an input's producer from the same canvas, including glob matches whose files do not exist yet. Computed output-path sources are observed before matching producers, so matching also works when input and output use different variable names. An input-bearing chain resolves variables in the entire output-path namespace. Other variables of a producer selected dynamically must already have defaults or cached values (use configure); unresolved values fail the run. This edge orders execution but invalidates by file content, not by the producer's execution or code. No producer means an external input. Multiple producers of the same normalized output and cycles are errors. Freshness includes file paths, glob membership and streaming BLAKE3 content digests. Missing or modified outputs invalidate their producer. Before execution all exact inputs and at least one file per input glob must exist; after a successful exit all outputs must exist and inputs must still have the same content. A violation fails the run and does not certify freshness. These attributes apply to finite executable blocks (including tty), not services, forms or buttons. outputs= declares files; the singular output= below controls stdout presentation. --no-deps still prepares computed variables and automatically found file producers.

  • env — optional, comma-separated list of declared meshfox:vars (see "Variables" below) this block wants in its own process environment. Each entry is a bare name (pass the declared variable through under the same name) or local=name (expose it under a different name in this block's own environment) — a leading $ on the variable-name side is accepted and stripped, but purely cosmetic: env="$X,LOCAL=$Y" and env="X,LOCAL=Y" mean exactly the same thing. A block with no env=, path-variable references, interpreter-variable references, application arguments bound to variables (child[lang=$L]) or deps= ? conditions never resolves or prompts for declared variables — this is what scopes "does running this block need to ask about anything" to the block itself, not the whole canvas. An env= entry naming a variable nothing declares is a meshfox validate error. An explicit alias may select a declared variable by argument, for example env="PDF_URL=PDF_URL_${lang}". Only ${argument} placeholders are accepted in the source name, using the current application's bound arguments. Selection happens once, before variable discovery and from= dependency planning; the selected value is never interpreted as another template. An explicit valid local alias is required. The resulting source must be a valid, non-reserved environment identifier and an actually declared variable within scope. fetch[lang=hy] therefore receives PDF_URL_hy as local PDF_URL. Freshness includes only that selected variable's value; running its from= source does not itself force the consumer to rerun. Static validation checks syntax and argument names, and all combinations of finite select/bool arguments (at most 4096); open string/int selections are checked when the application is planned. Errors identify the application and selected name. This syntax is confined to env=; it does not add parameterized variable declarations, dictionaries, case conversions, or recursive substitution. A selected canvas variable remains a global reference even if a same-named argument shadows it locally. Web/TUI block contracts show the source template and selected names/from= sources for applications observed in the session, without exposing variable values. Contracts also show explicit deps and implicit via var dependencies in the same always-visible list, with links to their producers and no separate dependency fold. They also list declared variable exports (exports: names and types), matching each meshfox:var from= to its exact producer node/block. Export signatures are visible before execution and remain visible when the block is folded; outputs continues to list files. Web button fences show this same contract in a hover/focus popover instead of beneath the button. The popover retains producer links and closes on pointer/focus departure or Escape; TUI button contracts remain inline.

  • interpreter="..." — a shebang-style command, e.g. interpreter="python3 -u", to run this fence's code under instead of the implicit bash/sh executor: the fence's own body is written to a fresh temp file and run as interpreter target-tmpfile (word-split the same way a #!/usr/bin/env -S ... shebang line's own arguments would be — quoting is honored, so interpreter="env \"my python\" -u" runs env with my python as a single argument). Optional; when set, lang no longer has to be bash/sh for the fence to count as runnable at all — lang becomes purely a syntax-highlighting hint, same role it already plays on a file node's own interpreter= (see "Node types" above, which this attribute is the fenced-block counterpart of — both parse the same way). Works on a tty block too — the interactive session runs directly under interpreter (given a real pty/terminal) instead of bash, e.g. interpreter="python3 -i" for an interactive Python REPL.

    A whole word starting with $ (e.g. $PYTHON in interpreter="$PYTHON -u") is substituted with the value of a declared meshfox:var of that name before word-splitting runs interpreter target-tmpfile — the same resolution/prompting machinery env= uses, just scoped to interpreter= instead of the block's process environment. Unlike env=, the $ here is mandatory, not cosmetic (interpreter= mixes literal words and variable references, so dropping it would make interpreter="PYTHON -u" ambiguous between "run the literal program PYTHON" and "run whatever PYTHON resolves to"); only a whole token is substituted, not $NAME embedded inside a larger word (interpreter="/opt/$NAME/bin/python" stays literal). The natural use is a from=-computed var — e.g. a setup block that creates/updates a venv and reports its interpreter path via MESHFOX_VARS_OUT (see "Computed variables" below) — so interpreter="$PYTHON -u" always runs under whatever interpreter that block last resolved to, without hard-coding a path in every fence that needs it.

    The temp file's extension follows the fence language (python → .py, javascript → .js, typescript → .ts, ruby → .rb, perl → .pl, php, lua, sql, go, bash/sh → .sh), because runtimes pick a mode from it — Node reads a .tmp file as CommonJS only, so an ESM import needs .js. A language with no entry, and the text/toml bodies of @name builtins, keep .tmp.

  • tangle — optional flag (tangle or tangle=true; tangle=false disables it); requires an interpreter= (meshfox validate error otherwise — without one the code runs under bash -c and never becomes a file). Instead of a throwaway temp file, the code is written to a hidden file with a stable name in the block's working directory, .meshfox-tangle-<canvas file>__<block>.<ext>, and the interpreter runs that. Runtimes resolve import/require, package.json, tsconfig and __file__ from where the script lives, so a tangled block can import the modules sitting next to the canvas without a sys.path.insert(0, os.getcwd())-style workaround.

    Both name components are sanitized: anything outside [A-Za-z0-9_.-] becomes _, runs of _ collapse, leading and trailing _ are dropped — so __ appears in a file name only as the separator, and one canvas's files can never be mistaken for another's in a directory holding many. <ext> is the language extension above. An unnamed block uses its implicit name. The file is rewritten only when the code changed (written to a side file and renamed, so it is replaced atomically), which keeps a file-watching dev server from reacting to a re-run of unchanged code. It is a cache of the canvas, not a source: edits to it are overwritten. If it can't be written (a read-only directory), the run fails with an error naming the path — there is deliberately no fallback to the temp dir, which would silently change where imports resolve from. Applies to captured and tty runs alike; works with @name builtins too (which read the file as $1). A tangled file stays on disk after the run. When a canvas's worker starts it removes this canvas's own tangled files that the document no longer has a block for (a deleted or renamed block, one that dropped the flag or changed language), in each directory one of its nodes runs in; files of other canvases, and of a canvas that was renamed, are left alone. Add .meshfox-tangle-* to .gitignore. Not part of the block's fingerprint.

  • extends — optional, comma-separated list of other blocks (a bare name in the same node, or node-id/block-name, as in deps=) whose code is placed in front of this block's own; the joined text is what runs. It exists so that definitions, the action and the tests can share one body in any language, with no module system: a block holds only functions, and each block that extends it adds one thing — run the step, or test the functions.

  def slug(text): ...
  assert slug("Hello World") == "hello-world"
  • Borrowed text only. An extended block is not run, is not a dependency and contributes no edge: the extending block declares its own deps=, inputs= and outputs=. Because of that an extended block holds definitions only: deps, inputs, outputs, lease, defer, tty, service and cache on it are a meshfox validate error (they would silently never apply). It is still an ordinary block and may be run on its own.
  • Order. The code of each entry comes first, depth-first (an extended block's own extends= precede it), in the order written, each block exactly once — a block reached by several paths is included at its first place. The pieces are joined with a newline, then the block's own code. A cycle is an error.
  • Inherited attributes. interpreter= is taken from the extended blocks when this block has none (so extends= alone makes a python fence runnable); if both set one they must be equal. env= is the union of the extended blocks' entries (first) and this block's; one local name bound to two different variables is an error. The language (bash, python, ...) must be the same everywhere. Everything else — deps, inputs, outputs, tty, default, always, ... — is not inherited.
  • Fingerprint. The extended code is part of the block's fingerprint, so editing a definition makes every block that extends it stale; the extended block itself is not part of any chain. cache cannot be combined with extends= (the cached output could not notice a change in the extended code).
  • Where it applies. A block with inline code: not a file node, a service, a form, a button or a meshfox fence. Only the executed text changes: the source editor, meshfox node block and the file still hold the block's own code. Entries take a block name only (no !, ? condition or [arguments]). tangle writes the joined text.
  • Errors. An unknown or cyclic parent, a language or interpreter mismatch, an unsupported parent and an unresolvable chain are reported by meshfox validate; running such a block refuses with the same message. Line numbers an interpreter reports are lines of the joined text: the extended code comes first, so a line of the extending block is shifted by the length of what it extends (the extended blocks' own lines are exact).
  • tty — optional flag (tty or tty=true); this block wants a real interactive terminal instead of the usual captured/streamed output — e.g. bash on its own (a login-style shell), or anything else that reads from its own stdin expecting a real terminal (ssh, git commit's editor, a REPL, a curses UI). Mutually exclusive with cache (meshfox validate error) — an interactive session isn't the deterministic "exit code plus text" cache saves/replays. A tty block may be a deps=/from= target of any other block, tty or not — each runner (CLI, TUI, the web UI) already hands the terminal/pty over at exactly that point in the chain and continues once it exits, the same way two chained tty blocks already hand it over twice, back to back (each still running its own deps= first, same as any other block). See "Interactive (tty) blocks" below for how the CLI and web UI actually run one, and that section's own autoclose for returning to the canvas automatically once it exits.
  • service — experimental, may change — optional flag (service or service="true"); starts a long-lived background process instead of one that runs to completion — a dev server, say. Unlike every other runnable block, "done" fires the moment the process is spawned, not when it exits: a deps= chain that reaches a service block continues immediately, without waiting for it. Mutually exclusive with tty/ cache (meshfox validate error) — a service is non-interactive by definition, and never exits under normal operation, so there's no deterministic "exit code plus text" for cache to save/replay either. A service block may freely be a deps=/from= target of another block, or have its own deps= — the chain-walk semantics already generalize, since "done" is just defined differently for it. See "Service blocks (experimental)" below.
  • output="markdown" — optional; changes how a cached run's captured stdout is written back (see "Cached output" below). By default it's wrapped in a passive ```text fence, shown verbatim. With output="markdown", it's spliced in as real Markdown instead — useful for a command that already prints Markdown worth rendering, e.g. a pandas DataFrame via df.to_markdown() (needs the tabulate package), which then renders as an actual table rather than preformatted text. With output="image", stdout is a picture — an SVG — and is stored as a Markdown image instead (see "Image output" below). Any other value (or omitting the attribute) keeps the default text rendering.
  • output-attrs="..." — optional; only with output="image" (meshfox validate rejects it otherwise). Image attributes — the same narrow grammar as {width=..}/{bg=..} after a Markdown image (see MARKDOWN.md's "Image size and background") without the braces, e.g. output-attrs="width=50% bg=#fff" — forwarded onto the generated image. meshfox validate also rejects a value outside that grammar. Part of the cache fingerprint: editing it makes the cached output stale.
  • autorun — optional flag (autorun or autorun="true"); in a live session (meshfox view/meshfox tui — never one-shot meshfox run, which has no ongoing session to react within), re-runs this block's full chain automatically whenever the resolved value of a variable it references (via env=/interpreter=, transitively through from=/ default_var=/choices_var=) changes, instead of waiting for a manual run. This is a trigger, not a new execution model — the session- freshness machinery above (fingerprint comparison, compute_forced_reruns's forward/backward cascade) is exactly what already decides whether a change actually makes this block stale; autorun just means a live session acts on that the moment it happens rather than waiting for the next manual "⛓ run chain". The natural source of such a change is a form fence's own Send (see "Form fences" below), but nothing requires one — any resolved-value change reaches it the same way, including one var's own from= source producing a different result. Compatible with tty: the session is started by the session's server and a connected client attaches to it — see "Interactive (tty) blocks" below for what that means when nobody is watching.
  • render="..." — optional; swaps this block's default display from "code + run controls" to a custom widget for the named kind, with the raw fence source reachable only via an explicit edit toggle (the web UI's pencil-to-edit-body affordance/the TUI's node-body editor — same uniform per-node mechanism every other kind of body content already uses, not a new per-block one). Parsed leniently — any value, including one a given meshfox version doesn't recognize, still parses; only meshfox validate rejects an unrecognized kind, so a canvas authored against a newer version doesn't fail to open on an older one, just falls back to the plain code view for that one block. The only kind today is "form" (see "Form fences" below) — deliberately left open for a future kind (e.g. rendering a cached block's own tabular output as a chart) rather than hard-coded to that one case.
  • fold — optional flag (fold or fold="true"), supported by the web UI and TUI. Starts this one block's own source collapsed, one click away via a dedicated ‹/› toggle in the web head or [code ▸] in the TUI head (c with that block selected in Document) — distinct from the block's own ▸/▾ fold toggle right next to it, which collapses the block as a whole (code and output together, same as it always has). Folding just the source this way never hides the block's output (unlike either of the other two levels — the block's own whole-block toggle, or collapsing the block's containing node — which both hide everything). Deliberately a per-block attribute, not a document-wide option (contrast unfold/"Options" above, which do apply to the whole document) — whether one particular block's own source is worth hiding by default has nothing to do with what every other block in the document wants. The natural case is a form/autorun pair (see "Form fences" below) whose own result (a live table, say) is the point to keep looking at, not its source. Session-only, same as the fold state itself — not remembered past a reload, and has no effect on fold= on meshfox:node (a different attribute on a different construct, for folding a whole node, not one block's source).

Supported languages without an interpreter= attribute: bash (sh is an alias for it). A fence in any other language never counts as runnable at all — not with an explicit name=, and not as a node's sole unnamed fence — unless it carries its own interpreter= (see above), which makes it runnable under that command regardless of lang. This is what keeps an ordinary Markdown document's own example fences (a yaml config sample, a json snippet, ...) from being mistaken for something to run just because meshfox was pointed at the file directly (the meshfox:canvas marker/.canvas.md suffix is only required for auto-discovery — see "CLI" below — an explicitly-given path still parses whatever heading structure it finds) — a plain documentation fence has no interpreter= either.

```python name="seed" interpreter="python3 -u" cache
print("seeding...")
```

```bash name="build" cache
cargo build --workspace
```

```bash name="test" cache deps="build"
cargo test --workspace
```

Running test here always runs build first.

In the web UI and the TUI (long-lived processes — meshfox view/meshfox tui, as opposed to a one-shot meshfox run invocation), running a chain this way skips re-running a pulled-in dependency (never the block actually requested — that one always runs for real) that already ran successfully earlier in the same session and hasn't changed since — same meshfox_core::fence::fingerprint (code/lang/interpreter=/env=/deps=) comparison crate::output's cached-output staleness uses, so editing a dependency's code (or its env=/deps=) makes it eligible to run again immediately, no explicit cache-busting needed. A skipped step still folds forward whatever it last wrote via from= (see "Computed variables" below), so a later step in the same chain that depends on that value isn't affected by the skip. Session freshness survives process restarts in the canvas session database. Applies the same way whether the chain ends in a tty step or not — a tty step is just another step of the same run driver (the web UI's /api/run/tty WebSocket only adds the terminal relay on top), so it consults the same per-session record.

For a built-in @name interpreter, runtime freshness also includes the embedded script's content identity and settings that affect its invocation (currently interpreters.agent.provider, with claude as the default, and interpreters.python_venv.python). Configuration is resolved from the block's execution directory, including included content's own directory. These changes invalidate dependent runs and restored session results without changing the authored fence's static cached-output hash. Unrelated configuration does not invalidate the run.

Execution processes inherit ordinary user environment variables. Meshfox replaces per-invocation context instead of inheriting it from a parent run: MESHFOX_ENV_NAMES is always the current block's local variable/argument names (empty when absent), MESHFOX_VARS_OUT is present only when the runner allocates an output path, and MESHFOX_BLOCK_LANG/MESHFOX_VENV_DIR are set only for the builtin that needs them. Inherited MESHFOX_CONFIG_* values are removed; builtin configuration is freshly exported. [process_env] cannot restore stale values for these reserved context keys. Coordinator and debug settings such as MESHFOX_SERVER_SOCKET continue to be inherited. These rules apply to captured, PTY, service and debug execution.

This skip cascades along explicit deps= edges: if a dependency runs for real, its consumers must run too, transitively. An implicit from= edge instead observes a computed value: re-running its source does not force the consumer when the value and its fingerprint remain unchanged. Naming that same source in deps= opts back into execution cascade.

Computed-variable sources are visited before a block's explicit action dependencies. The runner re-evaluates freshness with their actual values before deciding whether a consumer (and its ! preparations) must run. Code changes in explicit deps= dependencies still invalidate closure fingerprints. Sources reached only through from= contribute their values, and producers reached only through inputs= contribute file contents.

always — optional flag (always or always="true"), opts a block out of the fingerprint-based part of this skip entirely: even unchanged and already run successfully this session, a ⛓ run chain that pulls it in as a dependency still runs it for real every time, whichever block pulls it in. Its explicit deps= consumers run too; consumers reached only through from= may still skip when the computed value is unchanged. For a step whose side effect isn't captured by "looks unchanged" — a migration that always drops and recreates a table before loading fresh data, say, where re-running is the whole point even though the migration script itself never changes between runs:

```python name="migrate" env="PGHOST,PGPORT,PGDATABASE,PGUSER,PGPASSWORD" always
...
```

That blanket "every consumer, every time" reach is exactly what makes always the wrong tool when only one particular consumer actually needs this block fresh, and that consumer is otherwise expensive enough to be worth still skipping when nothing changed (a bulk data load, say) — an always migration ahead of it would now force that load to rerun on every single chain run too, session-freshness skip defeated for it entirely. For that narrower "run exactly when this one consumer runs, and only then" shape, mark the consumer's own deps= entry for it with a trailing ! instead (see deps above) — deps="paths/resolve,schema/migrate!" on the loading step, with plain (no always) migrate, ties migrate's freshness to the loading step's own decision, rather than forcing it (and cascading from it) on every run regardless of what anything downstream actually needs.

Forcing or skipping a run: --fresh, --skip-fresh and session reset

"Already ran this session and hasn't changed" is remembered across restarts (the session, stored per canvas next to its other .meshfox/ state) and is judged by the block's own text and variables — not by files it reads or produces. A cargo build, a test run or an install step can therefore look fresh long after what it depends on changed, and a plain meshfox run skips it as a dependency. Besides the per-block tools above (always, a ! on the consumer's deps= entry), three switches act on a single invocation or on the whole session, from outside the document:

  • meshfox run --fresh <block> — runs every block of the chain for real, this once (the requested block always does anyway). Nothing is forgotten: the fresh results are recorded as usual, so a following plain run may skip again, and other blocks' records and submitted form values are left alone. Meaningless with --no-deps, which is rejected together with it. The same flag is the fresh parameter of the MCP run tool.
  • meshfox run --skip-fresh <block> — the opposite lever: the block named on the command line is judged exactly like a pulled-in dependency and skipped when its freshness record still matches (code, env=, its deps= closure and any inputs=/outputs= fingerprint), so an install or build step that is requested directly only does its work when something changed. A block that is always, has no recorded run yet, or whose dependency ran for real this pass still runs. Rejected together with --fresh. Without the flag a block requested by name always runs, as before.
  • meshfox session reset — forgets everything the session remembers: every block's freshness record and every submitted form value, so the next run executes every block and asks for any form-supplied variable again. Finished runs stay as history (marked stale); the canvas file and any saved <!-- meshfox:output --> cache are untouched. The same as the web UI's "reset session" button, and the MCP session_reset tool.

Block arguments and applications

Signature parsing, typed binding, canonical application addresses, local process arguments and argument substitution in artifact paths are implemented. Concrete applications can be launched through CLI/API run endpoints. Parameterized dependency graphs support named bindings, local/global reference forwarding, canonical shared steps, execution cascades and sync edges. Exact file inputs infer arguments from producer output templates and recursively resolve their inputs. Interactive CLI, TUI and web launches use typed argument forms; MCP accepts a separate literal args object. Arguments are never persisted in the canvas variable cache.

POST /api/args accepts {path, block, args, noDeps} (path is an array of node ids, block an application address, and args a map of names to literal string values). noDeps defaults to false. It returns the resolved owner nodeId and path, whether the bound chain needs a terminal (tty, autoclose), the block definition, ordered fields (name, type, prompt, choices, required, resolved, optional value), and a canonical block address or null while mandatory arguments are missing. A suggested value does not imply resolved: required defaults must still be confirmed. Supplying an answer overrides a binding in the address and treats the answer literally. Preparation does not execute blocks. The returned address is used by ordinary run/TTY endpoints, preserving application-specific history, locks and logs. Bare manual launches offer the whole signature; a complete explicit application does not ask again. Global variable preflight runs after argument binding.

Web and TUI show each block's argument signature and declared inputs/outputs before launch. Argument rows show name, type, select choices and mandatory status. Optional defaults are labelled default; required defaults are labelled suggestion. File rows retain the declared templates (including placeholders), without implying that these are already resolved paths. In web these rows remain visible when the block's source or whole code/output body is folded.

A sequence of meshfox:arg declarations immediately before a named runnable fence defines that block's signature. Only blank/whitespace lines may intervene. Declarations cannot cross node boundaries, prose, headings, or another fence; an unattached declaration is a validation error. Declarations inside code fences are examples, not signatures. There is no block= or fence args= attribute.

Each declaration accepts name, type, choices, choices_var, default, prompt, and required. Names use environment identifiers ([A-Za-z_][A-Za-z0-9_]*) and must be unique within the signature. Types reuse variable types: string (default), signed 64-bit int, bool (true/false), select with literal choices, and date/datetime (see "Date and datetime variables"). Defaults are literal values and must satisfy the type. Dynamic defaults (default_var), from, secret, and session are outside this first contract; dynamic choices are choices_var=, below. The declaration order controls the manual-run form only.

An argument without a default is mandatory. required makes an argument mandatory even with a default: that default is only a UI suggestion. A manual form submission passes its accepted values explicitly. Dependencies and noninteractive calls never prompt for missing arguments. Optional omissions use the literal default. Output-path inference also counts as passing a value.

Applications use named bindings only, appended to the existing block address:

application ::= [ node-id '/' ] block-name [ '[' bindings ']' ]
bindings    ::= binding { ',' binding }
binding     ::= arg-name '=' arg-value
arg-value   ::= bare-literal | quoted-literal | reference | spread
reference   ::= '$' identifier | '${' identifier '}'
spread      ::= '*' reference

An empty binding list, positional values, unknown names, and duplicate bindings are errors. Bare literals are nonempty and contain no whitespace or any of ,[]=!$"\\. Double-quoted literals use JSON string escaping; quoted $LANG is literal text, whereas unquoted $LANG is a whole-value reference. Embedded reference interpolation is not supported in binding values. The enclosing Markdown attribute has its own escaping layer; for example:

deps='search[query="ACME, Inc."],extract[lang=hy]'

The shared attribute reader also accepts single-quoted outer values (literal contents, no escape processing). This extends the double-quoted/bare grammar, needed to carry the double-quoted binding strings above without ambiguity. An apostrophe within a binding string can be written as JSON \u0027. Dependency lists split at commas outside brackets and quoted strings. A forced dependency retains its existing suffix, e.g. extract[lang=hy]!.

Fan-out over a list. name=*$LIST (a spread) binds name to each element of the list variable LIST in turn, so one deps= entry names one application per element — see "List variables". It is written only this way: a bare literal may contain * but never $, so *$LIST is unambiguous, and a quoted "*$LIST" is literal text. A reference to a list without the * (name=$LIST) is an error that says what to write.

Bind names, resolve references, fill optional defaults, validate every resolved value, then construct the canonical application identity. Binding order is irrelevant. Integers normalize to decimal (01 and 1 are identical), booleans to true/false; strings and select values retain their exact contents. Identity consists of the existing block address and all effective bindings sorted by argument name. Explicit and defaulted equal values share identity. The web source block displays canonical application states separately. A new running or queued application is selected automatically; the application selector switches between observed variants without mixing their output. Run controls stay disabled while any variant is busy, and Kill targets the selected running application. Session reconciliation restores variants after reload, including persisted start time and ledger ID. The default selection prefers a currently running application (then queued), otherwise the most recently started retained result; ledger ID breaks timestamp ties. A new run takes over even when it repeats the same variant that was previously newest. Manual selection remains available.

The web history dialog for a source block lists finished runs of all its applications, newest first, with each canonical address shown alongside its arguments. /api/run/history with a bare block name returns this combined list; an explicit application address returns only that application. Each entry includes block, which is used with runId to replay its own output. Stale runs remain visible after a session reset. Retention remains per application.

Cache, history, locks and cycle checks use this identity; identical applications in one graph share a step. Graph expansion is limited to 128 nested applications and 4096 distinct applications; exceeding either limit is a diagnostic error.

A dependency reference resolves against canvas values overlaid by the caller's local arguments. References to computed globals add their from= sources to the graph before action dependencies. An edge with an unobserved computed value stays pending until its source completes; runners rebuild the graph with actual values. Newly discovered applications obtain their own run locks before execution and are recorded in history under their canonical addresses. Conflicts discovered during replanning stop the chain with a lock-conflict event.

Arguments are immutable and local to an application. They are exported to its process without an env= opt-in and are available in metadata substitutions and dependency bindings. They do not interpolate source code or mutate canvas variables. Different blocks may declare the same argument name. An argument shadows a same-named canvas variable locally; explicit env= aliases targeting an argument name and reserved MESHFOX_* argument names are validation errors.

inputs/outputs support argument placeholders such as ${lang}. Resolve canvas variables first, leaving local argument placeholders in producer output templates. Match a requested exact file path against those templates, derive bindings, validate them and fill optional defaults to identify a producer application. All mandatory arguments must be determined. Repeated placeholders must agree; multiple valid binding sets or producer applications are errors. Equivalent candidates deduplicate by canonical identity. Substitutions cannot perform transformations such as lower(lang). Existing cwd-relative path normalization and file-content freshness semantics continue to apply. Captures are substrings of the normalized requested path; after typed canonicalization, the bound producer must still declare that exact path. Matching is bounded to 20000 recursive steps per requested file and 128 literal/capture parts per template.

Inference runs for exact inputs whether the file exists or is missing. Glob inputs select already existing files and finite default producer applications; they never enumerate arbitrary argument values or select choices to invent missing files. Use exact file inputs to request absent parameterized outputs. Unresolved computed input variables remain pending until their sources are observed; planning sentinels are never inferred as argument values. Equivalent candidates from multiple outputs of one application deduplicate; distinct valid applications are an error.

Explicit deps retain execution cascade and ! semantics. File dependencies retain value semantics: an extraction producing identical CSV contents does not by itself force its file consumer to run. The graph is expanded lazily from the requested application or requested file. No generated copies are written to the canvas. Arbitrary return values and application-scoped computed-variable exports are deferred; the initial parameterized pipeline communicates through declared files.

Node-scoped meshfox:var declarations are supported and not deprecated; validation emits no warning for them. Choose by what the value is:

  • a variable is mutable, cached state that a person confirms once and later runs reuse (a credential, a region, a working directory). It has one value at a time, so a changed value is a change of the same history.
  • an argument belongs to one application of one block and is part of its identity: import[file=a.csv] and import[file=b.csv] have separate history, locks, freshness and logs, and are never cached.

Root configuration variables stay the place for shared configuration.

The signature accepts choices_var= on a select argument (mutually exclusive with a literal choices; meshfox validate rejects an undeclared name). It names a declared meshfox:var whose resolved value, split on commas like choices=, is the argument's choices — typically a from= variable populated by a listing block. Choices are looked up only when a launch form is prepared: POST /api/args runs the source blocks the choices_var chain needs (exactly like the pre-run variable form does for choices_var on a variable, with an empty environment) and returns the field with its real choices. If the value still cannot be computed, the field carries choicesPending ({"var": <the variable's name>, "reason"?}, the shape every status payload uses — see "Choices that could not be computed") and no choices, and the application is not bound. An answer is checked against the substituted choices; an application address bound directly (import[file=x]) is only checked when its form is prepared, since binding a block performs no lookups. Dependencies and noninteractive runs never prompt: without a value for a required argument they fail as before.

Parameterized file pipeline example

The extract signature below owns one language per application. A merge requesting the HY CSV derives extract[lang=hy], whose PDF input derives download[lang=hy] from the download output template.

<!-- meshfox:arg name="lang" type="select" choices="en,hy" -->
```python name="download" env="WORK_DIR" outputs="$WORK_DIR/pharm_am_${lang}.pdf"
# Download the language-specific PDF, using os.environ["lang"].
```

<!-- meshfox:arg name="lang" type="select" choices="en,hy" -->
```python name="extract" env="WORK_DIR" inputs="$WORK_DIR/pharm_am_${lang}.pdf" outputs="$WORK_DIR/pharm_am_${lang}.csv"
# Extract one language's CSV, using os.environ["lang"].
```

```python name="merge" env="WORK_DIR" inputs="$WORK_DIR/pharm_am_en.csv,$WORK_DIR/pharm_am_hy.csv" outputs="$WORK_DIR/pharm_am_merged.csv"
# Merge both CSV files. File dependencies infer the two extract applications.
```

For explicit execution dependencies the equivalent application spelling is deps="extract[lang=en],extract[lang=hy]"; unlike the file-input example, that opts into execution cascade.

Button fences

A ```button fence is an ordinary runnable fence — name=/default=/ deps=/always all mean exactly what they already mean above, with no new rules — whose own body is never executed as code. Its whole point is its deps= chain: a prominent shortcut to run some other block's chain from wherever in the document is most convenient to put a button, rather than wherever that block itself happens to live.

```button name="full-import" default deps="parsers/step-5"
🚀 Запустить полный импорт
```
  • There's no separate label=/title= attribute — the fence's own body is its caption, rendered directly on the button (a web UI/TUI's usual small run icon, but as one prominent, human-readable button instead). Falls back to the block's own name when the body is blank.
  • The body is never executed — running this block never treats it as code. meshfox validate rejects interpreter=, cache, env=, or tty alongside lang="button": all four presuppose a real process of the block's own, which a button fence never has.
  • name=/default= are what give a button its "alias": a button fence living directly on the root node with name="full-import" is reachable as meshfox run full-import for free — the root's own blocks need no path segments — the same addressing every other runnable fence already has, nothing new. deps= can name another button fence just as freely as any other block; the usual cycle detection covers it the same way.
  • always is deliberately not implied by lang="button" — see its own entry above ("the block actually requested... always runs for real"): the button itself never benefits from always, and forcing it onto the button's deps= chain would conflict with always's existing "every consumer, every time" semantics. A pipeline step that genuinely needs to never be session-skipped still opts in with its own always, same as it would with no button involved.
  • Scope: deps= only ever resolves against node ids in the same file, same as a button's own addressing.

Form fences

A ```form fence, same non-executing family as button above, is a small visual form for setting one or more node-scoped meshfox:vars (see "Variables" below) without opening the document-wide "configure variables" modal — a way to give a canvas a real input surface instead of only ever prompting before a run. A form is also a step of a run chain: see "A form as a step of a run chain" at the end of this section. Its body is a line-based list, one field or text per line, rather than code or a caption:

<!-- meshfox:var name="REGION" type="select" choices="us,eu" -->

```form name="pick-region" send="Apply"
field var="REGION" label="AWS Region"
```

```bash name="show-region" env="$REGION" autorun
echo "region is $REGION"
```
  • field var="NAME" [label="..."] — one declared meshfox:var shown as one labeled input, in document order; label= overrides the display text, falling back to the variable's own prompt (itself defaulting to name) when omitted. Rendered using exactly the same per-type control the document-wide "configure variables" form already uses for that variable's own type/choices (a checkbox for bool, a filterable list for select (see "Choosing from a select"), a masked input for secret, ...) — a form fence doesn't invent a second type system, it's a different view onto the same meshfox:var declarations. Every field var= must resolve to a variable actually visible in scope from the form's own node (the same node-subtree rule a node-scoped env= reference already follows — see "Variables" below) and must not name a from=-computed variable — submitting a value for one would let a human override what its source block computes, exactly what from= exists to prevent; meshfox validate rejects either case.

  • Dynamic choices_var= and default_var= on the referenced variable work in a form fence too. Loading its fields resolves those references transitively and runs the needed from= source blocks and their deps= chains, with the same empty environment and graceful unresolved fallback as the pre-run form (see "Dynamic default/choices" below). Only the form's declared fields are displayed; source variables are not inputs. Computed choices remain available when a field already has a session value.

  • send="..." — the Send button's own caption, on the fence's info string (not a field line) — falls back to a plain "Send" when omitted.

  • name="..." — required, same addressing convention as any other runnable fence (meshfox run-style node-id/block-name, or a bare name within the same node). A form has no code to execute, so running it directly (meshfox run <form>) runs its deps= chain and then parks on the form — see "A form as a step of a run chain". It is sent by whatever submits it: the web UI's own rendered Send button, the TUI's inline field-and-Send rendering (i to start editing it, or a click), meshfox form submit or the MCP form_submit tool.

  • text "..." — an explanatory line shown above the fields, in document order (a JSON string literal, or the rest of the line when unquoted). A form with text and no field is a pure pause: "do this elsewhere, then continue".

  • cancel="..." — the Cancel button's caption on the fence's info string, shown while a run is parked on the form; falls back to "Cancel". Accepted only on a form fence (meshfox validate rejects it elsewhere).

  • In the TUI a form is drawn in the same frame as a code block (┌─ form · name header, │ gutter, └─ foot) and is selected with [/] in document order, with the same highlight rules: accent border and a filled header while the Document pane has focus, dimmed when another pane has it, › replacing the corner only while focused. Clicking a field, the Browse/Pick marker or Send selects it too. Enter (or i) starts editing — the header then reads · editing and the foot lists the keys — Esc stops editing and keeps the selection, and r says a form is not run.

  • Nothing happens live as a field is edited. Clicking Send is the one and only moment values take effect: every field's current value is committed together, into a session-lifetime store (the web server's own process memory for meshfox view; the TUI's own process memory for meshfox tui) — never the on-disk per-document cache, since every variable a form targets is already implicitly session-scoped by being node-scoped in the first place (see "Variables" below). This is also exactly the moment autorun (above) reacts: submitting triggers every autorun-flagged block whose own variable closure the just-changed values reach, automatically, with no separate "run" click — the combination this section opened with. A value committed this way is visible to every block's env= for the rest of that session, not just whichever autorun block happened to react to this particular Send.

  • Reset. A form can put its fields back to their declared defaults (default, or the resolved default_var) — the way to enter a series of similar records without clearing every field by hand. Two triggers:

    • the bare reset flag on the fence's info string (```form name="tx" send="Add" reset) resets automatically after a Send, once every autorun block that Send started has finished with exit code 0. If any of them fails, nothing is reset, so the user can fix the input and send again. A field marked with the bare keep flag (field var="CURRENCY" keep) is left alone by this automatic reset.
    • the form's Reset button (web UI), [Reset] marker or Ctrl-r while editing (TUI): always available, clears every field, keep or not.

    A reset deletes the form's variables from the session store (and from its on-disk copy, so a restart does not bring the values back); it commits nothing and never triggers autorun — only a Send does. Output and status of blocks that already ran are left as they are: a reset says nothing about what those runs did (use "reset session" to forget them). Like Send, a reset is refused (409) while a foreground run is in progress. reset is accepted only on a form fence (meshfox validate rejects it elsewhere); keep is only read on a form's field line. The server endpoint is POST /api/form/reset (respectKeep: true spares the keep fields, as the automatic reset does); it announces the reset on /api/watch as form-reset, so every open web tab and TUI shows the defaults. meshfox form reset and the MCP form_reset tool call it (as meshfox form submit and form_submit call POST /api/form/submit).

  • A form can only be sent while no foreground run is in progress, with one exception: a run parked on this very form (below). A plain run, a tty session, or the gap between a chain's steps counts; a service doesn't. Send commits the values and starts every autorun block they reach, so sending on top of a running chain would race its inputs, and an interactive session can last as long as its user likes. The server refuses (POST /api/form/submit answers 409) before committing anything, and the web UI and TUI disable Send (and say why) while one is running. A form that triggers a tty block therefore holds further Sends until that terminal is closed or the program exits.

  • A form fence can't declare its own env=, interpreter=, cache, tty, service or autorun — none of those presuppose a real process of the form's own, which it never has (same reasoning button already gives for the ones it rejects). It can declare deps= (what must run before the form is asked) and be named in another block's deps=.

A form as a step of a run chain

A form named in some block's deps= (or addressed directly by meshfox run) is a step of that chain. When the run reaches it, the run parks there until a person sends or cancels the form:

  • The step always parks — it is never skipped as "already fresh this session": the answer is the point. Its fields are prefilled from the session.

  • The run stream emits awaiting-form (nodeId, block, runId) and /api/watch announces form-awaited / form-resolved. GET /api/form/awaiting lists the forms runs are parked on; GET /api/form/fields reports awaiting, cancel and text for one form.

  • Send (POST /api/form/submit, the web/TUI Send button, meshfox form submit, the MCP form_submit tool) on the form a run is parked on commits the values into the session and releases the run; the 409 rule above does not apply to that form (other forms are still refused while the run is in progress). The run then emits form-answered — the non-secret answers, so transcripts and meshfox logs record what was decided — and the form's step-end. autorun blocks reached by the Send are started as ordinary runs, exactly as for an unparked form.

  • Cancel (POST /api/form/cancel, the web Cancel button, Ctrl-x in the TUI form) fails the run: an error event ("form ... was cancelled"), exit code 1, and no later step runs. Killing the run also ends the wait.

  • The answers reach later steps through env= like any session variable. A variable that is a field of a form step is not required before the run starts (neither at admission nor in the pre-run variable form) — unless a step before the form already reads it.

  • Clients. The CLI asks the fields at the terminal ([Enter] sends, c cancels); with no terminal there is nobody to ask, so it cancels the form and fails with a clear error. The MCP run tool returns status: "awaiting_form" with the form's address (not an error) and the run keeps waiting in the worker; the agent answers with form_submit. The web UI scrolls to the waiting form and shows its text; the TUI opens it for editing.

  • Coordinator notifications. The worker also tells its coordinator (watcher_protocol::Message::Notify, one-way, best effort, never delaying a run) when a run starts (run_started), parks (form_awaiting), is released (form_resolved), and when any run ends (run_finished / run_failed). A coordinator that does not know the message ignores it.

  • Scope: like button, a form's own addressing only ever resolves within the same file.

Constraint fences

A ```starlark constraint fence, living in any node's ordinary Markdown body alongside its prose and runnable code, is a sandboxed Starlark contract over the document tree — a way to assert invariants a canvas should hold (e.g. "every node tagged table has exactly one file child") as part of the document itself, checked by meshfox check rather than enforced only by convention. There's no dedicated node type for this (see "Node types" above) and no restriction on what else shares the node's body — a node may carry prose, runnable fences, and any number of constraint fences, in any order:

## Entities
<!-- meshfox:node -->

```starlark constraint
for n in self.descendants():
    if "table" in n.tags:
        files = [c for c in n.children() if c.type == "file"]
        if len(files) != 1:
            fail(n.id + ": expected exactly one file child, got " + str(len(files)))
```

### Users
<!-- meshfox:node tags="table" -->
...

The constraint flag (```starlark constraint, mirroring the bare cache/default/tty flags on a runnable fence) is what opts a fence in — a plain ```starlark fence with no flag is left alone, e.g. a documentation example showing Starlark syntax that was never meant to actually run. An optional name="..." attribute labels a fence for meshfox check's output when a node carries more than one (see below); unnamed fences are identified by their enclosing node's id alone if it's the node's only one, or <node-id>#<n> (1-based, in document order) when it isn't.

There's no separate "document" object: doc is simply the root node, and every node — doc included — exposes the same navigation methods, so a constraint scopes a check to its own subtree with self.descendants() the exact same way it would reach the whole document via doc.descendants(). self is the node whose body the fence lives in — a constraint typically governs the subtree of the node it's placed in, so a natural place for one is directly in the node that's the natural parent of whatever it's checking (like Entities above), rather than needing a dedicated node of its own just to sit above that subtree.

The script sees:

  • doc — the document's root node.
  • self — the node whose body this fence lives in, so a script can find its place in the tree without hardcoding its own id.
  • sum(items, start=0) — the one builtin added on top of Starlark's own (which has no sum): adds up an iterable's items, start for an empty one. render fences have it too.
  • On every node (both of the above, and any node reached through them):
    • .id, .title, .type (a string, e.g. "file"), .parent (a string, or None for the root), .tags (a list of strings), .text (its own raw Markdown body, unrendered) — plain read-only fields.
    • .created_at, .updated_at — the node's own createdAt/ updatedAt (see "Timestamps" above), as the literal RFC3339 string, or None if unset. .created_at_ts, .updated_at_ts — the same two values as a Unix timestamp (int), or None — Starlark has no datetime type, so this is what makes real arithmetic possible (e.g. self.updated_at_ts - other.created_at_ts > 86400) and, being offset-independent by construction, compares correctly across nodes even when their own created_at/updated_at strings carry different literal offsets (plain string comparison doesn't). There's no now() builtin — a constraint comparing against "how old is too old" needs a literal cutoff (timestamp or int), not a relative one: exposing the evaluator's own wall-clock time would make the same script pass or fail depending on when meshfox check happens to run, breaking the same-input-same-output property every other constraint here has.
    • .children() — its direct structural children (same tree Canvas::children walks — not extra meshfox:edge parents).
    • .descendants() — everything in its subtree (children, their children, ...), not just direct children — the usual way to scope a check to "this fence's own node's subtree" (self.descendants()) instead of the whole document (doc.descendants()).
    • .node(id) — the node with that id anywhere in the document, or None.
    • .nodes_with_tag(tag) — every node in the whole document whose tags includes tag, regardless of where it's called from. Prefer self.descendants() filtered by tag when a constraint should only govern its own subtree; reach for this only when a rule is genuinely document-wide.
    • .content() — a file-type node's own target, read fresh off disk and confined to the canvas's own directory (same boundary/cap the display="code" preview uses — see "Node types"), as a plain string. .json()/.yaml()/.toml() parse that same content each their own way, handed back as nested Starlark dicts/lists/strings/numbers/bools; .csv() parses it as tabular data instead — a list of dicts, one per row, keyed by header. All five return None — not an error — for anything that isn't a file node, has no target, doesn't resolve, or (for the four parsers) doesn't parse that way; a constraint decides for itself whether that's fail-worthy. This is the sandbox's one deliberate window onto something outside the document itself — but only a target the document's own author already committed to in the node's own link, and only when whatever's running meshfox check chose to make disk access available at all (see below).
  • fail(msg) — records a violation without stopping the script, so one constraint can report every offending node in a single run instead of just the first. A script that never calls fail passes.

Beyond these, and Starlark's own built-ins (len, range, string methods, list/dict comprehensions, ...), the sandbox has nothing: no network access, and no way to mutate the document — a constraint only ever reads and reports. The only I/O it can trigger at all is the five file-node methods above, and even those don't run arbitrary code or touch an arbitrary path: the target was the document author's own choice, visible right there as the node's link, and the read only happens when the tool driving meshfox check (or the server, evaluating every constraint on every canvas load) passes it a base directory to resolve targets against in the first place — an in-memory canvas that was never read from a real file makes every one of these calls return None. Evaluation is resource-bounded (instruction count, call depth, heap size); a script that times out or errors (syntax error, unbound name, ...) counts as a failing constraint, with that error as its one message. Each constraint fence gets its own fresh sandbox — nothing persists between them, and nothing carries over between one meshfox check run and the next; every file-node target that exists in the document is read and parsed once per meshfox check run (while preparing the fences to evaluate, not lazily per-fence), whether or not any fence actually calls these methods on it.

meshfox check runs every constraint fence in the document and reports pass/fail per fence, exiting non-zero if any fails (or if the file doesn't parse — validate's job, which check implies). Distinct from meshfox validate: validate asks whether the file parses as a well-formed canvas; check asks whether the document, once parsed, actually satisfies whatever rules its own constraint fences declare.

Computed Starlark views

A starlark render fence computes a Markdown view of the document using the same read-only doc/self API and bounded interpreter as a constraint:

```starlark render name="tag-counts" output="markdown"
counts = {}
for n in [doc] + doc.descendants():
    for tag in n.tags:
        counts[tag] = counts.get(tag, 0) + 1
print(md.table(
    headers=["Tag", "Nodes"],
    rows=[[tag, counts[tag]] for tag in sorted(counts)],
))
```

render is an explicit presence flag, name is optional, and output may only be "markdown" (the default). constraint and render are mutually exclusive. Shell execution attributes (interpreter, cache, deps, etc.) are not accepted on a computed view. A plain starlark fence stays inert. Computed views are not runnable blocks or dependency targets, and do not contribute to a node's constraint status. meshfox check (and the MCP check tool) nevertheless evaluates every render fence and fails on one that raises an error, and meshfox render [--node ID] [--name NAME] prints the computed Markdown of the selected blocks (each preceded by a <!-- render: node/name --> line when several are selected; a failing block's diagnostic goes to stderr and the exit status is non-zero).

The worker evaluates views on its current authored document snapshot and sends renderResults alongside each node in its response. These are temporary presentation data: they never change the node's text or body revision, are never written to the source or cached-output regions, and are not input to subsequent evaluations. Changes anywhere in a document with computed views cause clients to fetch the complete updated snapshot. Static/PDF export uses the worker's computed results as well.

WebUI and TUI show a compact starlark · name header, a success/error mark, and the generated Markdown underneath. Source starts collapsed; click the header toggle to expand it. In TUI, [/] select a block and c or Enter toggle its source. There is no Run button. Errors replace the result with a diagnostic, independent of constraint violations. Static export uses a collapsible <details> source section and the rendered result. Generated fences are always content, never new executable blocks.

print() appends Markdown lines. Each block gets a fresh module and the constraint runtime's tick, heap and stack limits, plus a 1 MiB output limit. A failing block discards its partial output. Diagnostics refer to lines of the authored script, rather than the document API's generated prelude.

The built-in md namespace returns native MarkdownFragment values:

FunctionResult
md.text(value)Escaped literal text.
md.raw(text)Explicit, authored Markdown without escaping.
md.color(value, color)Inline foreground color, #rgb or #rrggbb.
md.background_color(value, color)Inline background color; nests with md.color.
md.link(label, url)A link with an escaped label and encoded URL delimiters.
md.code(text, lang="")A fenced code block with a safe delimiter length.
md.table(headers, rows)A GFM table from lists; every row must match the nonempty header width.

Builders distinguish ordinary strings/numbers (data to escape) from fragments (already-constructed markup). Inline fragments can be placed in links and table cells without escaping their markup again; table insertion still protects pipe separators. Plain cell line breaks become spaces. Block fragments such as tables and fenced code cannot be inserted into inline positions. print(fragment) emits its Markdown; str(fragment) explicitly loses the type information, so passing that string to another builder treats it as literal text. Fragment size is limited to 1 MiB. No color syntax is introduced here; future helpers can return the same fragment type.

Interactive (tty) blocks

A tty block (see its flag above) hands its process the real terminal instead of the captured/streamed output every other block gets — CLI and web UI each do this differently, since only one of them actually has a terminal to hand over.

  • CLI — before running a tty step, meshfox run checks that both stdin and stdout are an interactive terminal; if either isn't (piped, redirected, CI), it errors out rather than hanging or silently running non-interactively. When they are, the block's process is connected directly to the real terminal (not captured line-by-line the way every other block's output is) — a script can read from the user, run vim, prompt for a password, whatever a normal interactive shell command could do. Earlier steps in the same deps= chain still run captured/streamed exactly as usual; only the tty step(s) themselves take over the terminal, handing it back once each one exits.
  • Web UI — meshfox view gives the block a real pseudo-terminal (not just piped stdout/stderr, which can't do cursor movement, raw input mode, or terminal-size queries) and streams it to an in-browser terminal, keys typed there going back to the process's stdin. Clicking run on a tty block opens this as a floating panel over the canvas (draggable/collapsible, like the node text editor), rather than filling in the node's own inline output area the way a normal run does.
  • Either way, output from a tty block is never written back into the file — that's what the cache conflict (above) rules out.

A tty session belongs to the session's server, not to whoever started it. In meshfox view/meshfox tui the pty is owned by the server process; a client that runs the block (or a chain that passes through it) is just its first viewer. Any number of clients can attach and leave, and closing the one that started it neither ends the session nor stops the rest of its chain — only the program exiting, or an explicit stop, does. So a tty block triggers like any other: manually, as a deps= target, or by autorun (after a form's Send, say). The server then starts it unattended and announces it to every connected client, and each client that isn't already showing a terminal attaches — the web UI opens its terminal panel, meshfox tui takes over the screen. With no client attached the program simply waits on its own input for as long as it takes (there is no timeout); it shows up as a running tty run, and stopping it ends the chain like any other kill. A tty session's terminal is as large as the smallest attached client's, and before any client attaches it is 80×24.

By default, once a tty block's process exits, its exit code (and whatever it last printed) stays visible until a deliberate action returns to the canvas — a keypress in meshfox tui, closing the panel by hand in meshfox view — same as leaving a real terminal window open after a command finishes. This also applies to viewers that attach to an existing session: the server sends its final outcome before closing the socket. The web panel and its minimized pill show Completed (exit 0), Failed (nonzero exit), or Killed, and the expanded panel offers a Close button. A lost connection is reported separately as "result unknown"; the TUI likewise never presents it as a process exit code. autoclose (a flag, autoclose or autoclose="true"; only meaningful on a tty block — meshfox validate rejects it on anything else) skips that and returns to the canvas the instant the process exits instead:

```bash name="shell" tty autoclose
bash
```

This only affects meshfox tui/meshfox view (both long-lived, with a canvas to actually return to) — plain meshfox run has no such distinction to make, a tty step there always just hands the terminal back the moment its process exits, autoclose or not.

  • A real terminal (CLI's own, or the web UI's pseudo-terminal) is a genuine tty, so the "commands run without a pseudo-terminal" note under "Cached output" below doesn't apply to tty blocks — a tool that auto-detects color support (cargo, git, ...) sees a real terminal and colors its output without needing --color=always.

Service blocks (experimental)

Experimental — the attribute name and behavior here may still change.

A service block (see its flag above) starts a long-lived background process — a dev server, most commonly — instead of one that runs to completion. Where every other runnable block's "done" means "exited", a service block's means "spawned": a deps= chain that reaches one continues immediately, and the process keeps running independently, tracked/observable/stoppable/restartable separately from the run that started it, until it's explicitly stopped or its owning process exits.

Dependency freshness tracks the live service instance. A successful readiness block (deps="server", for example) is reused while that instance remains running and its dependency fingerprint is unchanged. Starting or restarting a service creates a new generation; stopping or crashing removes the live generation. Either change invalidates readiness results and their transitive consumers. Merely encountering an already-running service in another chain does not invalidate them. Live service generations are never restored from persisted session results.

Ownership: a service is tied to the lifetime of whichever OS process spawned it — the meshfox view server, meshfox tui, or a meshfox run invocation. Its address and owning PID are tracked by the per-canvas run ledger under .meshfox/ (gitignored local machine state). If that ledger already names another live (or dead-but-unreleased) process when a service block is about to start, the user is always prompted — kill the recorded process and start fresh, or cancel (the block errors out) — never resolved silently either way, whether the recorded owner turns out to still be alive or not.

  • Web UI (meshfox view) — a started service gets a second title-bar badge on its node (next to the constraint badge), green while running, red if it crashed; clicking it (or the toolbar's own aggregate pill, next to the constraints one) opens a panel listing every service this server process knows about — status, log, CPU/memory, uptime, and Stop/Restart. Unless the service has autostop, closing every browser tab does not stop a running service or exit the server early — the whole point of tracking it here is that it survives a page reload.
  • TUI (meshfox tui) — a node with a service block shows a small dot next to its title: white when idle, a smoothly pulsing green while running, red if crashed. s opens the services list; inside it, s stops the selected service and r restarts it. The footer shows a running/crashed count. Quitting the TUI (q/Esc) stops every service it owns first — unlike the web UI, there's no separate long-lived server behind a TUI session for a service to keep being tracked by once it ends.
  • meshfox run — starting a service no longer means the command exits once its chain finishes: it stays attached, streaming each service's own output to the console (prefixed by block name), until either every service it started has stopped on its own or the user hits Ctrl-C — which stops all of them before exiting. A one-shot invocation that never started a service behaves exactly as before.

Restart is local only: restarting a service restarts just that one process, with the exact parameters it was last started with — it never touches, reruns, or even looks at anything that depends on it.

Graceful stop and declarative lifecycle

Stop enters stopping and disables automatic restarts before cleanup begins. For an ordinary foreground service, meshfox sends SIGTERM to its process group and tracked detached descendants, allows 30 seconds for shutdown, then escalates to SIGKILL and reaps the processes. A shell exiting early does not shorten its children's grace period. The ledger address stays claimed until shutdown finishes. Restart awaits that same stop before claiming a new generation. Worker shutdown, CLI Ctrl-C, and quitting an embedded TUI await service cleanup too.

interpreter="@service" selects a built-in, worker-managed lifecycle supervisor and implies the service flag. The fence body is YAML; yaml is the recommended language hint. tty and cache remain invalid for a service. Unknown YAML keys, empty commands and invalid durations are errors during validation and startup. Unlike script-backed builtins, @service is only supported on managed runnable fences, not file-node interpreters or direct one-shot interpreter execution.

start: docker compose up --build --wait
stop: docker compose stop
monitor: docker compose logs --follow --no-color
check: ./scripts/check-app.sh
check_interval: 5s
restart_on_fail: true
restart_delay: 2s
stop_timeout: 30s
PropertyDefaultMeaning
startrequiredBash command; foreground by default, one-shot with oneshot: true or monitor.
oneshotfalseTreat start as a bounded command that acquires an external resource; monitoring is optional.
reuseabsentProbe before initial start: 0 borrows an existing resource, 1 starts and owns it, any other code is an error.
stopabsentExplicit cleanup command for external resources; zero exit means success.
monitorabsentOptional long-running log command after acquisition, including when reusing a resource. Implies one-shot start for compatibility.
checkabsentLiveness command: zero exit is healthy, any other exit is failure.
check_interval5sInterval between checks and timeout for each check; checks never overlap.
readynoneRepeated startup readiness command; exit 0 admits consumers. Separate from health check.
start_timeout30sTotal deadline for reuse, start and readiness, including a hanging probe.
restart_on_failfalseRetry nonzero foreground/start exits or failed/timed-out checks.
restart_delay2sInitial retry delay; doubles on consecutive failures, capped at the greater of 60s and this value. Resets after a minute of healthy operation.
stop_timeout30sDeadline for the stop command, or grace period for foreground termination without a stop command.

Durations are strings with ms, s or m suffixes (including fractions), from 1ms through 1h. Commands run with bash -e -c, using the cwd and environment captured at service startup. Restart reuses that captured configuration even if the canvas has since changed; ordinary shell expansion works inside commands. No additional YAML templating or command interpolation is performed.

The supervisor runs as an async task inside the worker, with a small idle process as its stable, separately killable identity. For @service, PID and resource counters describe this identity rather than a container or foreground child. States are starting, running, restarting, stopping, stop_failed, crashed, and stopped. Starting completes when the foreground process is spawned or one-shot start succeeds, followed by successful ready if configured. Health checks are separate from this readiness barrier. Automatic retries change the live generation, invalidating dependency freshness just like an explicit restart.

A monitor exiting only means its log connection ended. Meshfox reconnects it after restart_delay, without rerunning start. Use check to detect failure of an external service; without it, a detached service's health is unknown. A clean foreground exit does not trigger restart_on_fail.

On Stop, meshfox first runs stop if present, keeping service logs flowing. Then it terminates/reaps local start/monitor processes, allowing up to one extra second for local termination after an explicit hook. Without stop, it sends SIGTERM and uses stop_timeout for local shutdown. Startup and restart delays can be interrupted by Stop. Stop also runs after a partially failed startup or failed check, before an automatic retry.

A failed or timed-out stop leaves stop_failed, preserves the recipe, logs and ledger ownership, and blocks Restart until a subsequent Stop succeeds. Killing local processes cannot prove external resources stopped. Abrupt worker death (SIGKILL/OOM) cannot run external cleanup; no detached watchdog is installed. Explicit force-takeover remains a hard recovery operation, not graceful shutdown.

Reusing external resources

reuse probes for an external resource before the first start of a service instance. Exit 0 borrows the resource and skips start; exit 1 executes start and makes this instance responsible for cleanup. Other exit codes, signals, timeouts and command-launch errors fail acquisition without running start or the automatic stop hook. Missing executables and unexpected statuses must not be translated to exit 1 by the probe. Without reuse, startup retains the existing ownership semantics. The service log records borrowing.

start_timeout covers the combined reuse probe, start and readiness wait. Ownership is claimed immediately before start, so a partial start or readiness failure still cleans up resources it may have created. On a borrowed resource, readiness is still required, but its failure only detaches local supervision. A failed health check on a borrowed resource is reported without stopping or automatically restarting it, even when restart_on_fail is true. Automatic retries of an owned instance preserve ownership and do not rerun reuse.

EventStarted by this instanceBorrowed by this instance
Last temporary lease releasedRun stop hook, terminate local processesTerminate local monitoring/identity only
Readiness failure or worker automatic shutdownClean up the acquired resourceLeave external resource running
Autostop on idle workerStop acquired resourceDetach supervision
Explicit Stop/Restart of an active instanceStop external resourceStop external resource

These rules also apply to worker signal handling and cancellation. Explicit Stop deliberately ignores borrowing and lease counts; automatic cleanup does not. After detachment the meshfox instance is stopped: it is no longer tracking the external resource. A later run probes again. As with other stopped instances, Stop on an already detached instance is idempotent and does not reacquire it.

oneshot: true allows a successful start command to exit without a monitor. The supervisor's identity stays alive until release/Stop or a failed check; without check, external health is unknown. Existing foreground services remain foreground when neither oneshot nor monitor is specified.

Reuse probes are not an atomic reservation against external actors. Another process can start the resource between probe and start; tools requiring strict ownership must provide their own atomic acquisition/locking. Abrupt worker death still cannot guarantee cleanup of acquired external resources.

The Linux tests in DEVELOPMENT.canvas.md demonstrate this with Lima: creation is persistent, the VM is leased through provisioning and tests, and a VM already running beforehand is preserved. Unexpected Lima statuses abort instead of claiming the VM. See the Lima list reference for the status probe and the start reference for non-interactive startup.

See examples/services.canvas.md for foreground and Docker Compose examples and a self-contained Compose fixture.

Invocation transcripts (meshfox logs)

The worker records each admitted fenced-block invocation and its dependency chain in the per-canvas session database. The transcript is independent of the per-block run ledger: it preserves ordered step starts, stdout/stderr, freshness skips, service startup/reuse, step outcomes, errors and the chain outcome. Steps not reached are marked explicitly. CLI, TUI, web, autorun and native canvas calls use this same journal; each requested CLI target is an invocation. No submitted variable values are stored as invocation metadata. TTY bytes and background service output after readiness are outside the transcript.

meshfox logs selects the latest chain. A block address selects its latest participation; --chain selects invocations by their requested target and shows the whole chain. --list lists matching invocations; --id N selects a chain, and --block PATH BLOCK narrows that ID to one block. --json emits NDJSON. The default view shows skips without old output. --effective additionally shows a snapshot of the exact source execution's output used by a skipped step, with the source block-run ID and time. Missing source output is explicit; a more recent execution is never substituted. Snapshots already recorded in a chain survive per-block history rotation and subsequent document edits.

--follow replays and tails only the selected invocation, using sequence cursors, and ends when it completes. Ctrl-C detaches without cancellation. meshfox console instead remains attached across invocations; its --timestamps flag prefixes each output line with the local HH:MM:SS.mmm at which the core recorded it (timeMs on /api/run/subscribe lines, shown in the machine's own time zone; the transcripts' time fields stay UTC); lines the core has no time for (service logs, ledger replays) show --:--:--.---. It prints the lines it has received before it polls for runs again, so attaching to a run in progress shows its buffered output at once rather than paced by the poll. All history access goes through the worker API, including when a worker must be started just to read saved logs. Reading a failed invocation returns exit 0; missing history or a read failure returns nonzero. Pre-existing per-block history is not reconstructed into invocation transcripts retroactively.

[session] max_chain_runs (default 100, minimum 1) retains completed transcripts independently of max_runs_per_block. max_chain_output_bytes (default 1 MiB) bounds output per invocation, including effective output, while preserving metadata and marking truncation. The recorded prefix is retained. Zero disables storing output; max_output_bytes = 0 also disables transcript output. A worker restart marks unfinished invocations interrupted and retains their recorded prefix. Output commits in batches of up to 64 events or 50 ms; step and terminal boundaries flush preceding output immediately. Storage errors mark a transcript incomplete without stopping execution. Session reset preserves transcripts. On a read-only canvas the journal lives in memory for the worker's lifetime, like the rest of the session DB.

Chain status (meshfox status)

meshfox status is a presentation of the same saved invocation journal that meshfox logs reads, folded into one row per step of the selected chain. It selects a chain like logs does (latest, a block address, --chain, --id N) and goes through the worker API; it never executes blocks. The steps planned for the chain are known from its start, so a step that has not begun is pending; a chain that ended before reaching a step marks it not-started. A step still running after its chain ended is reported killed.

Per step the report carries node, block, state (pending, running, ready for a service that reported readiness and has no end event of its own, succeeded, failed, skipped, killed, not-started), and when known startedAt, durationMs, exitCode, reason and tests (passed, failed, ignored, summed from cargo test, Playwright and node --test summary lines in the step's recorded output; absent when none were recognised). failures lists each failed or killed step with the names of failing tests and the last 20 output lines. Output is bounded by max_chain_output_bytes, so on a truncated transcript (run.truncated) test counts may be incomplete.

--json prints no terminal formatting: one snapshot object, or with --watch newline-delimited snapshot, step, failure and done events. --watch polls until the chain ends. The exit code is 0 for a running or succeeded chain, 1 for a failed, killed or interrupted one, and 2 when the chain cannot be read.

Saved-journal statistics (meshfox stats)

meshfox stats computes a snapshot over retained invocation journals for the canvas. A block address selects its participation in any chain; --chain selects chains requested for that target and includes their dependencies. --since accepts a nonnegative relative duration (7d, 2w, 1h, 30m) or an RFC3339 timestamp with any offset, filtering by invocation start time. --json returns the full report; ordinary output shows overall results, duration median/p95, skip counts, slowest blocks, frequently failing blocks, and recent outcomes for a selected block.

Every report states the retained and selected chain counts and oldest/newest start times. Rotation limits coverage; requesting a time window does not imply that its complete history is available. There are no persistent statistics aggregates or new database tables, and the logs-list pagination limit does not limit statistics. The worker reads metadata from its journal under the shared session-DB lock and computes the report off the async executor; the CLI only calls /api/stats. Reading statistics never executes user blocks.

Success rate divides known successes by successes plus failures, keeping cancellation, interruption, running and unknown outcomes separate. Skips and service startup/reuse are separate from successful executions; unreached and pending steps are separate from attempts. Skip rate divides skips by skips plus attempts. An estimate of avoided work sums known durations of skipped sources; it is not net wall-clock savings.

Chain duration uses the admission-to-terminal span of succeeded/failed chains; block duration uses completed succeeded/failed steps with a recorded duration. A running chain contributes no chain duration, but its completed steps may contribute block durations. Cancelled/interrupted and unfinished steps do not contribute durations. Median averages the two middle samples for even counts; p95 uses nearest rank. Empty duration samples and success-rate denominators are null in JSON and n/a in text. Chain and step durations are kept separate. Incomplete transcripts are flagged and missing outcomes are not inferred as successes. Metadata survives output limits, so output truncation/disabled output storage alone does not exclude a journal from statistics. Block recent history is limited to the last twelve observed outcomes; ranking lists show five entries in text and all blocks remain available in JSON.

Read-only canvases

A canvas whose file, or the directory its .meshfox/ state would live in, can't be written — a read-only mount, a checkout owned by another user, a container image — is served read-only. Blocks still run; nothing is ever written to disk on its behalf.

How it's decided: when a worker takes the per-file lock (worker_lock::try_acquire), the attempt itself is the probe. The canvas is read-only if the file refuses a write-open, or if .meshfox/ can't be created or the lock file can't be opened in it (EACCES/EPERM/ EROFS). Any other failure — a full disk, an I/O error — is a real error, not a read-only canvas. The verdict is fixed for the worker's lifetime.

What changes:

  • No lock, no port file. A read-only worker holds no flock and isn't discoverable; any number of them may serve the same canvas side by side (every meshfox run, view or MCP session starts its own).
  • State lives in memory only. The session database (undo history, run ledger, run history, freshness) and the variable cache are in-memory for the worker's lifetime. An existing .meshfox/<file>.env is still read, but an answer is never written back, so it's asked for again next time.
  • Nothing is persisted into the file. A cached block's output stays in the session; the run itself is not an error.
  • The disk exceptions: both live under an owner-only directory in the system temp dir, meshfox-readonly-<hash of the canvas path>/. A python_venv block's venv is <file>.venv there, and a node_env block's environment is <file>.node, reused across runs and cleaned up by the system, instead of .meshfox/<file>.venv / .meshfox/<file>.node. display="table" caches are tables-<pid>/ there (see "Table previews"): per worker, because several read-only workers may serve one canvas and a DuckDB database has a single writer; removed when the worker exits, and the directories of dead workers are swept the next time one starts.
  • Config is still read — <canvas_root>/.meshfox/config.toml and syntax/ are read-only inputs, as is the global ~/.meshfox/.
  • Edits are refused. The worker answers every request that would change the file — node and canvas edits, PUT /api/canvas*, PUT /api/options, undo/redo/history jumps — with 403 and a message saying why (and write_raw, the single place that writes the file, refuses as well). GET /api/info reports {"readOnly": true}. meshfox node ..., undo/redo and the MCP node_* tools surface that message; run, check, validate, node show/find work as usual.
  • Clients: the browser UI shows its read-only badge with no Edit button; the TUI's e (source editor) and H (history) only report why they're unavailable.

Restricting a core: --read-only and --no-exec

Two switches, accepted by every command (meshfox doc.canvas.md run --read-only go) and also set by MESHFOX_READ_ONLY=1 / MESHFOX_NO_EXEC=1 (anything but empty, 0, false, no or off counts as set; the MCP server is configured this way). They restrict the core — the worker process serving the canvas — for its whole lifetime; neither can be lifted again, and a .meshfox/config.toml cannot set them (a checked-out repository must not be able to loosen, or silently impose, what the person running it asked for). A core's restrictions are the process's own: a meshfox view started with a switch hands it on to every worker it spawns.

--read-only serves the canvas exactly as "Read-only canvases" above describes — no lock, no port file, session state in memory, every edit refused with 403 — but on request, whether or not the files are writable. Blocks still run. Because such a core holds no lock and is not discoverable, it is always a private worker: it never joins a core already serving the canvas (nor one an external server_socket coordinator would hand out), so any number of them can serve one canvas next to each other and next to a normal one. Two consequences:

  • service blocks are refused (403, "a read-only core never starts services"). A service outlives its run and holds ports, and the run ledger that notices a second start is in memory here, so two read-only cores would never see each other's. The same refusal applies to a canvas that is read-only because of its filesystem. tty blocks work: the pty belongs to the one core that started it.
  • run --detach is refused: a detached run is handed to a shared core.

--no-exec serves the canvas with execution disabled: no block, file node, service, tty session, debug shell, canvas-call or form autorun starts a process. Everything else works — viewing, editing, history, search, Starlark views (they run inside the core), display="table" previews and opening files. It is independent of --read-only: a no-exec core holds the canvas's lock like any other, so a meshfox run that finds it is refused with a message saying why, and meshfox run --no-exec is refused at once. Both switches together give a pure viewer that is safe to start any number of times.

What stays unavailable is anything that needs a block's output: a variable with from=, and the choices_var=/default_var= that name one, are not computed (a select stays without choices and reports choicesPending with the reason "running blocks is switched off"). A value already cached in the session or a .meshfox/<file>.env still applies.

GET /api/info reports {"readOnly", "noExec"}. --no-exec is enforced twice: the HTTP layer refuses the routes that start code with 403, and every function that spawns a process refuses on its own, so a route nobody thought of still cannot run anything. The clients read it: the web UI shows a no exec badge and reports the refusal instead of attempting a run, and the TUI does the same in its status line; meshfox mcp forwards the worker's refusal text. A core started with --no-exec is joined by later commands like any other core, so they are refused too — the restriction belongs to the worker, not the client.

Restrictions and shared or external cores. A restriction is never lost on the way to a core, and a process started with --no-exec is never the client of a core that can run code:

  • A --read-only command never asks a server_socket coordinator for anything. meshfox view (which otherwise hands the canvas to the coordinator and exits) serves the canvas in a private worker instead, and says so on stderr ("the configured external coordinator … is not used").
  • A --no-exec command that does use a coordinator says so in its request: the watcher protocol's open and get_port messages carry "no_exec": true (absent means unrestricted, so a coordinator that predates the field reads them as before). A coordinator starts the core it creates with --no-exec, and answers a request for a canvas it already serves with a core that can run code with an error rather than that core. A worker that asks its coordinator to open another canvas (a file node pointing at one) sends the same field. The macOS daemon and meshfox serve honour the field; the VS Code extension only starts workers that can run code, so it refuses such an open (logged in its output channel) instead of opening the canvas.
  • Whatever answers, the client checks. Before using a worker it did not start — one an external coordinator returned, or one found through the canvas's lock file — a --no-exec process reads GET /api/info, and a worker whose noExec is not true is refused with a message that says to stop it or to open the canvas with --read-only (a private core). A worker that cannot be asked counts as able to run code. This is what keeps a coordinator that ignores no_exec (an older macOS daemon or editor extension) from handing out an unrestricted core.

What --read-only does not isolate. The core keeps no state of its own, but the blocks it runs are ordinary programs: whatever a script writes into the project, or any other shared resource it uses, is shared with every other core. Nor are plain runs of one block in two read-only cores excluded from each other (that exclusion is the run ledger, which is per core here). Use --no-exec as well when nothing should be run at all.

Packed canvases (.canvas.tar.xz)

A packed canvas is one file that carries a canvas together with everything it reads: includes, images, linked files and the canvases it links to. It is meant for handing a canvas to someone else, and it opens without unpacking by hand. The file is a plain tar archive compressed as one xz stream, so tar xf opens it too; its name ends in .canvas.tar.xz. A reader recognizes it by the xz magic bytes (FD 37 7A 58 5A 00), not by the name.

Layout

The archive root is the pack's root: by default the directory of the entry canvas (the canvas pack was given), or a directory above it that pack --root names. Every other path is relative to it, the entry canvas's included: with a wider root it sits below the root (co2/README.canvas.md) and the manifest's entry says where. Entries are regular files and directories only (pack writes files only; a reader accepts directory entries); a reader refuses symbolic links, hard links, devices and FIFOs. The first entry is the manifest, .meshfox-pack.json:

{
  "format": 1,
  "entry": "README.canvas.md",
  "created_by": "meshfox 1.0.0",
  "files": [
    {"path": "README.canvas.md", "size": 1234, "blake3": "<64 hex digits>"},
    {"path": "img/sensor.webp", "size": 5678, "blake3": "<64 hex digits>"}
  ]
}
  • format is the format version, 1 here. A reader that does not know a version refuses the file and says which version it found.
  • entry is the path of the canvas to open.
  • files lists every regular file except the manifest itself, sorted by path bytewise, with its size and BLAKE3 hash. The manifest holds no time, user or machine, so the same files always give the same manifest.
  • Archive entries are sorted the same way. Timestamps, owner and group are zero, names are empty, and a file's mode is 0755 if the source was executable by its owner and 0644 otherwise (0755 for directories). For the same input and meshfox version the archive is byte-identical.

Paths. A path is UTF-8 with / separators and is relative: no leading /, no empty, . or .. component, no backslash, NUL or drive prefix, at most 1024 bytes. Two paths that differ only in letter case are a conflict, so a pack unpacks the same way on a case-insensitive filesystem. .meshfox/ (the canvas's own state: cache, history, session data, secrets, @tmp) and the manifest's own name are reserved: they never appear as files of the pack.

What goes in

meshfox pack starts from the entry canvas and follows every local reference that meshfox itself would read:

  • include targets, file node targets, and local images and links in node bodies (Markdown assets inside an included file resolve against that file's directory, as when the canvas is opened);
  • every canvas such a reference names (a .canvas.md, or an .md with the canvas marker) is packed whole and followed the same way, each reference resolved against its own file's directory. A canvas reached twice is packed once. With --shallow those canvases are not followed: they and what only they reference stay out, and a file node that points at one is left as it is (it will not open from the pack).

Cached output regions that are already part of a canvas's own text travel with it; a reference inside one that cannot be packed (an image a block wrote to @tmp, say) is left out with a warning instead of failing the pack, since the output is derived data its author cannot edit. Nothing else of a run goes in: no .meshfox/ state, no files a block wrote. Collecting follows references and never walks a directory, so .meshfoxignore plays no part in it.

Every reference stays inside the pack's root. A reference is resolved against the file it is written in and must land below the root: by default the entry canvas's own directory, so ../docs/x.png from co2/README.canvas.md needs the root widened to the directory that holds co2/ and docs/ (--root ..). A reference that lands outside fails pack with an error naming the canvas, the node and the path, and the root that would make it work, and writes nothing. It covers ../ paths that climb above the root, absolute and ~ paths, and symbolic links that lead out, also when [files] allow would let meshfox read them on the sender's machine. A pack is complete or it does not exist. Inside the root nothing is rewritten: the canvases are packed byte for byte, at the places they were, and read the same files through the same relative paths. --root must be an existing directory above (or equal to) the entry canvas's own. The same goes for a local reference whose target is missing, and for a target that names a dir variable or @tmp: a pack must not depend on the sender's environment. External URLs and #anchors are not references to files. The declarations of dir and file variables stay in the canvas text; their values are not packed.

Files are read through the same access checks as everything else (FileAccess, see "Configured file access"), never by walking the directory separately.

Reading and limits

A reader checks the whole file before anything is used: the manifest first, then every entry against it. It refuses the pack, naming the first problem, if the xz stream is truncated or corrupt, an entry has another type than file or directory, a path breaks the rules above, a file is missing from the manifest or the manifest lists one the archive lacks, a size or hash differs, entry is not a listed canvas, or a limit is exceeded. Nothing is written to disk on a refused pack.

The limits are part of the format version and the same for the command line and the browser: at most 20 000 entries, 256 MiB for one file and 512 MiB in all, and a manifest of at most 8 MiB. They are enforced while decompressing, on the bytes actually produced, never on sizes the archive claims, so a small file that expands without end stops at the limit.

Opening a pack

Whoever opens a pack has not written its canvases, and a canvas can run code, so a pack is opened as untrusted:

  • meshfox view PACK unpacks into a fresh temporary directory and serves the entry canvas with --read-only --no-exec (see "Restricting a core"); the directory is removed when the view ends. The restrictions are handed on to every core started from it, so a nested canvas opened by clicking its file node is read-only and no-exec too. The unpacked directory is the one place its canvases may read outside their own directories: view names it in MESHFOX_TRUSTED_ROOT (see "Configured file access"), which every core it starts inherits. That comes from the person running the command, never from the pack, which carries no configuration (.meshfox/ is never in it); and the pack's files are all there is below it.
  • The browser editor opens a pack like a folder: the unpacked files are its virtual directory, and the root of that directory is the boundary a canvas reads within (as it is for any folder picked or dropped there), so includes, images, links and nested canvases resolve inside the pack, ../ included. It runs as noexec already. Edits are saved by downloading a new pack; the file that was opened never changes.

Commands

  • meshfox pack CANVAS [-o FILE] [--root DIR] [--shallow] [--list] [-f] writes the pack. --root DIR sets the pack's root (see "What goes in"); it is handed to the worker as an absolute path, and the worker reads below it with the same checks as for the canvas's own directory, plus that root, which the person running the command has chosen. FILE defaults to the canvas's name without .canvas.md (or .md) plus .canvas.tar.xz, in the current directory. An existing file is not replaced without -f. --list prints what would go in (path, a tab, size in bytes, one file per line) and writes nothing; it fails in the same cases pack does. Anything left out on purpose (a canvas under --shallow, a reference in cached output) is reported on stderr as a warning. A failure leaves no partial file. The reading is the canvas's worker's job (GET /api/pack and GET /api/pack/archive), like every other read of a canvas and what it references; the command stores the result. That worker is a private, read-only one (as with --read-only), so packing writes nothing next to the canvas (no .meshfox/, session database or lock file) and does not disturb a worker that is already serving it.
  • meshfox unpack PACK [DIR] checks the pack as above and extracts it. DIR defaults to the pack's name without .canvas.tar.xz; it must not exist or must be empty, and no existing file is overwritten. It prints the path of the entry canvas. The result is an ordinary directory: it is not read-only and not no-exec, and has no .meshfox/. When the pack was made with a root above the entry canvas's directory, the canvases read files outside their own directories, which meshfox allows for such a directory only where [files] allow says so; unpack then prints a note saying so, with the line to add (it writes no configuration itself). meshfox view PACK needs none.
  • meshfox view PACK as above; --read-only and --no-exec are always on and cannot be turned off.

Variables

Document-scoped configuration values a canvas wants from whoever runs it — an install prefix, a log level, an API token — asked for once and then remembered, the same idea as CMake's cached variables or a ./configure step. Declared as <!-- meshfox:var ... --> comments, most naturally inside the root node's own body, where a declaration is document-wide and shows up in ./configure/the web UI's "Configure variables":

<!-- meshfox:var name="INSTALL_PATH" prompt="Install prefix?" default="/usr/local/bin" -->
<!-- meshfox:var name="LOG_LEVEL" type="select" choices="debug,info,warn,error" default="info" -->
<!-- meshfox:var name="API_TOKEN" secret -->
<!-- meshfox:var name="REGION" default="us-east-1" required -->

A meshfox:var may also be declared inside any other node — a node-scoped variable, for a value only one block (or a small cluster of related ones) genuinely needs, e.g. a search term only one ad hoc query block uses. It's visible only to references inside that node's own subtree, including runnable fences, form fields, and $NAME / ${NAME} references to dir/file variables in file, link and include targets (meshfox validate catches a reference from outside it — see meshfox_core::validate_var_scope — though run/view stay lenient about it, same as every other validate-only check); it's implicitly session (see below) whether or not session is written on it explicitly, so it's never written to the on-disk cache and never shows up in the document-wide configure list — asking about it up front, or remembering an answer past the current session, wouldn't make sense for a value this narrowly scoped. session only ever changes whether an answer is remembered, though, never whether one gets asked for — a variable with a default and no required still resolves silently from it, every time, the same as any other non-required declaration; being session just means there's nothing left afterward to fall back on before that default kicks in. For a value meant to be typed fresh each run despite having a sensible default (a search term like this one, not a fixed path), pair it with required too — the combination this section's own session bullet already calls out as guaranteeing "a real prompt on every run":

<!-- meshfox:var name="MANUFACTURER_QUERY" prompt="Manufacturer name" default="Sanofi" required -->

For a non-secret session file or dir variable, the worker also retains the last resolved path from an admitted run (one that resolved all its variables, planned its chain and acquired its locks; a refused run retains nothing) for file previews and other file reads. This display value survives worker restarts until session reset, and grants only that exact file (file) or directory tree (dir). It is separate from run inputs: it does not satisfy required on the next run or populate the document's variable cache. Node-scope validation still applies to targets referencing the variable. Ordinary file/dir path defaults already require confirmation; required makes that intent explicit and also requires confirmation for a default under @tmp.

Variable names still share one flat namespace across the whole document regardless of where they're declared — a duplicate name=, root or not, is a meshfox validate/declared_vars error either way.

Attributes:

  • name — required. What a runnable fence's own env= (see "Runnable code fences" above) refers to when it wants this variable — declaring one here doesn't, by itself, put it in any block's environment; that's opt-in per block, see "Consumption" below.
  • type — string (default), int, bool, select, dir (see "Directory variables" below), file (see "File variables"), date or datetime (see "Date and datetime variables"), or list (see "List variables"). It decides how to prompt (a bool prompts y/n, a select shows its choices as a menu, a date a calendar, ...) and how a UI renders an input for it. Every value is validated against it, wherever it comes from, so a block never receives an int that is not an integer or a date that is not a day (it still arrives as a plain string): a new answer (--set, an interactive prompt, a submitted form or API request) is rejected before it is saved; a value supplied by the process environment, the variable cache or shared config, and a from= block's output, fails the run with an error naming the variable, the bad value and where it came from. Such a value is never silently replaced by the default or asked for again — a stale cache entry is corrected with --set or by editing the cache file. A secret's value is never echoed into the message. Only a declaration's own default is taken as written.
  • mode — type="file" only: open (default) or save, see "File variables".
  • ext — type="file" only: a comma-separated list of extensions (mp4,mkv) the path picker highlights/filters by. A hint — a value is never checked against it.
  • prompt — question text to show when asking for a value; defaults to name itself.
  • default — used if nothing else resolves the variable (see below). Mutually exclusive with default_var (meshfox validate error).
  • default_var — default_var="OTHER_NAME": this variable's own default comes from another declared variable's resolved value instead of a literal string — e.g. a default install path computed by actually running pwd in some relevant directory (declare that as its own from=-computed variable, then reference it here by name). Not a $-prefixed overload of default= itself — default='s value is genuinely freeform text, where a $name convention would be ambiguous with a literal default that happens to look like one (unlike env='s dollar-stripping, which is safe only because an env= entry is always a name reference, never literal text). Mutually exclusive with a literal default and with from= (meshfox validate error either way) — a computed variable is never prompted for, so it has no default to supply in the first place.
  • choices — comma-separated; required when type="select" and choices_var isn't given. Mutually exclusive with choices_var (meshfox validate error).
  • choices_var — choices_var="OTHER_NAME", same idea as default_var but for choices (the referenced variable's resolved value is split the same comma-separated way a literal choices= already is) — e.g. a select's options populated by actually running aws list-regions. Requires type="select", same as a literal choices= does. Mutually exclusive with a literal choices and with from=, same reasoning as default_var.
  • secret — flag (secret or secret=true). A secret variable is never read from or written to the on-disk cache (see below) and never pre-filled anywhere — the only way to supply one without an interactive prompt is --set/the process environment. It's asked for fresh every single time it's needed.
  • required — flag (required or required=true). A required variable's own default is never taken silently, even when nothing else resolves it — it still needs one explicit interactive answer, the same as if it had no default at all, with default offered as the prompt's own pre-filled suggestion (so confirming it is just pressing Enter). This is only a one-time confirmation, not a standing "ask every run": like any other non-secret answer, whatever's confirmed is written to the cache, so the next run of any block referencing it resolves straight from there without prompting again. Without required, a variable with a default is simply used, never prompted for, unless it has no default at all (in which case it always needs an answer either way).
  • session — flag (session or session=true). Never read from or written to the on-disk cache — unlike secret, input isn't masked; this is about lifetime (never remembered past the current meshfox run invocation), not confidentiality. Always true for a node-scoped declaration (one outside the root node) whether or not it's written explicitly — see above. Combined with required, this guarantees a real prompt on every run rather than silently falling back to a cached/default answer — e.g. picking which of several configurations to deploy, every time. A plain session without required still silently resolves from --set/the environment/a default when one of those supplies it, exactly like any other declaration — it just never remembers the answer for next time. A variable referenced by more than one block in a single meshfox run invocation is still only ever prompted for once within that invocation — session only skips the cache, not the same once-per-invocation reuse every other variable already gets (see "Consumption" below). Mutually exclusive with from= (a computed value is already never cached, so session on it would be a no-op).
  • from — from="node-id/block-name" (or a bare from="block-name", meaning a block in the same node this variable is itself declared in — the same shorthand deps='s same-node form uses; for a root-declared variable that's the root node, same as before node-scoped variables existed). Makes this a computed variable: instead of being prompted/defaulted/cached, its value comes from actually running the named block and reading back what it wrote to its own MESHFOX_VARS_OUT file — see "Computed variables (from=)" below. Mutually exclusive with default/default_var, required, secret, session, and choices_var (a meshfox validate error to combine any of them with from) — none of those mean anything for a value that's never cached, defaulted, or prompted for. type/choices still apply, validated against whatever the source block actually produced.

Named environment builtins

@python_venv and @node_env accept optional whitespace-separated name=NAME and var=VARIABLE options inside interpreter= (each at most once), for example interpreter="@python_venv name=xls var=XLS_PYTHON". name selects .named/NAME under the usual canvas-local environment directory; without it the existing directory is used. With no canvas path the fallback is .venv/.named/NAME or .node-env/.named/NAME. This also applies to read-only temporary environments. Names match [A-Za-z_][A-Za-z0-9_-]*; paths are not accepted. var selects the key written to MESHFOX_VARS_OUT, defaulting to PYTHON or NODE, and matches [A-Za-z_][A-Za-z0-9_]*. Declare a matching computed meshfox:var from= to consume that output. The two options are independent; changing var alone does not isolate packages. Unknown, duplicate or invalid options fail resolution before launching the script. Other builtins accept no options. The resolver passes the selected directory through MESHFOX_VENV_DIR / MESHFOX_NODE_DIR and the output key through MESHFOX_ENVIRONMENT_VAR; normal computed-variable dependency planning and interpreter substitution apply.

Choosing the Python for @python_venv

A new venv is built with the first of these that applies:

  1. [interpreters.python_venv] python = "python3.12" in config.toml (a command name or a path; exported to the script as MESHFOX_CONFIG_INTERPRETERS_PYTHON_VENV_PYTHON). A command that is not found is an error, not a fallback.
  2. For a toml fence, the pyproject's own project.requires-python: python3 if it satisfies the specifier, otherwise the newest python3.N on PATH that does. The specifier is evaluated for >=, <=, >, <, ==, !=, ~= and ==X.Y.*, comma-separated; anything else is reported and ignored (the system python3 is used). If no interpreter on PATH satisfies it the block fails and lists the versions it found.
  3. Otherwise the python3 first on PATH.

An existing venv is rebuilt when (1) or (2) applies and the venv contradicts it — its minor version differs from the configured interpreter's, or it falls outside requires-python — so editing the config key or the block's requires-python is enough; the requirements are then installed again. With neither (a text fence and no config key, or a toml fence without requires-python) a venv is never rebuilt, whatever Python it was made with. Only a venv meshfox itself created is emptied (it carries a .meshfox-venv marker, or the python3-mf launcher); any other directory at the fallback .venv is reported as a mismatch and left alone. Rebuilding keeps the .named/ environments nested in the same directory, but anything running from the old venv at that moment (a service, a tty session) loses its interpreter. Changing the config key invalidates the block's runtime freshness (see above), so a re-run really executes the script.

The @agent builtin

interpreter="@agent" hands the fence body to an AI agent CLI as a prompt. It is one of the @name interpreters embedded in the binary (its whole behavior is the shell script crates/core/src/builtins/agent.sh; copy it out and point interpreter= at the copy to change it) and works on any fence language that accepts interpreter=; text is the usual choice. It takes no options, and the agent CLI itself (claude or codex) is not shipped: it must be on PATH.

Whether the run is interactive is decided by the fence, not by an extra attribute: the script checks whether its standard output is a terminal.

FenceInvocationResult
ttyclaude "<prompt>" / codex "<prompt>"A real interactive session on the inherited terminal, with the prompt as its first message. Nothing is captured; the session's exit code is the block's.
any otherclaude -p -- "<prompt>" / codex exec -- "<prompt>"A one-shot run. Standard output is captured as the block's output, so cache and output= work as for any block.

Provider. interpreters.agent.provider in .meshfox/config.toml (next to the canvas, resolved from the block's execution directory) or in ~/.meshfox/config.toml selects claude (the default when unset) or codex; the local file wins key by key. Meshfox exports the setting to the script as MESHFOX_CONFIG_INTERPRETERS_AGENT_PROVIDER, like every flattened config key. Any other value fails the block with @agent: unknown interpreters.agent.provider '<value>' (expected claude or codex) and exit code

  1. The provider is a property of the machine, not of the document, so it is deliberately not a meshfox:var.

Interpolation. Before the prompt is passed on, $NAME and ${NAME} are replaced by the value of a name the block itself declares: a local name from its env= (env="TOPIC=$GLOBAL_TOPIC" makes $TOPIC) or a bound argument (see "Block arguments"). The script is told these names through MESHFOX_ENV_NAMES. Three rules apply:

  1. A reference to any other name ($PATH, $HOME, a typo, an undeclared variable) is left exactly as written.
  2. The bare form matches a whole name only: $TOPIC matches, $TOPICS is a different name and $TOPIC_X is not $TOPIC followed by text. ${TOPIC} is unambiguous.
  3. $$NAME is always a literal $NAME, whether or not NAME is declared.

The prompt is read once and substituted in a single pass; inserted values are never scanned for further references, so a value containing $OTHER stays literal. Nothing in the prompt is evaluated or run through a shell.

Access. Meshfox adds no restrictions of its own: the agent runs as the current user, and its tool, sandbox, approval and directory-trust settings apply. tty changes only interactivity; capturing output does not disable the agent's tools. Meshfox's own restriction flags (--no-exec, see "Restricting a core") still refuse to run the block at all, like any other.

Freshness. As described under "Runnable code fences" (session freshness), the script's content and the effective provider are part of the block's runtime identity: changing the provider invalidates restored results and dependent runs, while unrelated config keys do not.

As a step in a chain it behaves like any other block, including as a deps= target and, with tty, as a step that takes over the terminal. An optional agent step is expressed on the consumer's edge, for example deps="fix?USE_AGENT" (see deps above).

Consumption (env=)

A meshfox:var declaration on its own does nothing to any block — it's only a name a fence's own env= attribute can reference (see "Runnable code fences" above). Only variables a block actually lists in its own env= are ever resolved or prompted for on its behalf, and only those end up in its process environment (under whatever local name it asked for): running a block that declares no env= at all never touches meshfox:var resolution, however many variables the document as a whole declares. This is what keeps, say, meshfox README.md run some-unrelated-block from ever being asked about an INSTALL_PATH that only some other block in the document actually uses.

meshfox run <path...> <a,b,c> resolves each requested block's (and its deps= chain's) own env= independently, right before that specific block runs — not the whole document's variables up front. If the same variable is referenced by more than one block in a single invocation (directly or via deps=), it's only ever resolved/prompted for once: the first block to need it answers the prompt, which is immediately fed back into that invocation's own resolved-answers (the same override slot --set occupies, not necessarily the on-disk cache — see session below), so every later block referencing the same variable in that same invocation just reads it from there without asking again.

Resolution

Resolving one declared variable (for whichever block's env= asked for it) tries, in order: an explicit override (meshfox run --set NAME=value, or a value submitted through the web UI's form) → the process environment → the on-disk per-document cache (skipped entirely for session; a secret value can be sitting in it if a caller explicitly opted it in — see below) → shared/global config (see "Shared/global config" below — not skipped for session) → the declaration's own default (skipped entirely for required — see above). Whatever isn't resolved by any of those needs an interactive answer — a terminal prompt for the CLI, a form for the web UI — which, for a non-secret, non-session variable, is then written to the cache so a later run of any block referencing the same variable doesn't ask again (this is what turns a required variable's mandatory first confirmation into an ordinary cache hit on every run after).

The cache lives at <dir>/.meshfox/<filename>.env, next to the canvas file itself (e.g. examples/hello.canvas.md -> examples/.meshfox/hello.canvas.md.env) — a plain NAME=value-per-line file, meant to be .gitignored, the same way CMakeCache.txt usually is. It's safe to hand-edit or delete: deleting it just means every non-secret, non-session variable gets asked about again next time some block's env= needs it.

Shared/global config ([[env]])

A value many canvases need in common (DB credentials, an API base URL, ...) can be declared once instead of being re-entered — or re---set — per document, in either or both of the same two TOML files meshfox's own tool settings (unrelated to meshfox:var) already come from, under a separate top-level env key (not merged with the rest of that table):

  • ~/.meshfox/config.toml — global, every project on this machine.
  • <ancestor>/.meshfox/config.toml — inherited by every canvas below that directory, with nearer files taking precedence. Search uses the canonical canvas directory and runs from the filesystem root down; the global file is applied only once.
[[env]]
# no path= -- applies to every canvas that reads this file
vars = { DB_USER = "alice" }

[[env]]
path = "~/work/projectA"
vars = { DB_URL = "postgres://projA-db/app", DB_USER = "projA_svc" }

path= scopes a global [[env]] section to canvases rooted under that directory — the same idea as git's includeIf "gitdir:...", needed so two unrelated projects' global entries can reuse the same variable name without colliding. It can be a single string or an array of strings (path = ["~/work/projectA", "~/work/projectB"]), matching if the canvas is under any one of them — handy for one section covering several unrelated directories without repeating the same vars= in multiple [[env]] blocks. It's optional in the project file too (rarely needed — the file is already project-scoped), resolved relative to the directory owning that config there instead of $HOME. Matching is by path component, not string prefix (a scope of ~/work/projectA never matches ~/work/projectAB), via canonicalize where possible. When more than one section in the same file matches a given canvas, the most path-specific one wins for each variable name (an unscoped section is the least specific); on a tie, the later section in the file wins.

Precedence: each nearer config file wins over more distant files and global, regardless of scoping specificity — this is the escape hatch for overriding an inherited value on one canvas tree. Within the resolution chain above, shared/global config sits between the per-document cache and each declaration's own default — an explicit --set/submitted-form override, the process environment, and an already-cached per-document answer all still win over it, same "override" story the cache itself already offers. Unlike the per-document cache, this tier is not skipped for a session declaration — session only means "never remembered past this one run", not "never read from anything but overrides/env/default", and shared config is a live external input the same way the process environment already is for a session variable.

Both the web UI and the TUI show, per field, when its currently-resolved value was inherited this way (a small "project"/"global" badge next to the prompt) — typing a new value overrides it normally, persisted to the per-document cache exactly like any other answer, so it's remembered for that one canvas from then on. A required variable satisfied only by shared config resolves silently, with no confirmation prompt — the same behavior a cached answer already has today.

A section can also list names in secrets = [...] next to vars — see "Secret store" below. Values written in vars are plain TOML, same caveat the per-document cache already carries.

Secret store

secret_store in .meshfox/config.toml (local or global; local wins) picks where secret variables are saved:

secret_store = "keychain"   # or "plaintext" (the default)

Explicit only — there is no auto mode, and an unknown value is an error rather than a silent fallback to plaintext. keychain is the macOS Keychain (service meshfox) or, on Linux, the freedesktop Secret Service on the session bus (gnome-keyring, KWallet, oo7-daemon, ...; items carry the attributes service=meshfox and account=<account>, so other clients such as secret-tool see them). Elsewhere, or with no reachable Secret Service or an unlockable default keyring, it fails loudly on first use. With it:

  • Opening a canvas, rendering inline forms, inspecting file access, and background freshness checks do not read the secret store. Run preparation reads only the variables needed by the requested chain, including indirect defaults/choices and argument references. Explicit secret configuration and management can also access the store.
  • Preparation resolves artifact-path variables before discovering producers, then prepares their inputs before the first block starts. If runtime from= values can change producer or argument selection, secrets reachable through the possible definitions are prepared conservatively in advance. This can include a secret for a branch that ultimately is not selected. Each secret account is read at most once per preparation; execution uses the prepared values and does not initiate further secret-store reads.
  • The web form's "save" checkbox on a secret field is labelled "save to keychain" instead of "save (plaintext)" and writes there. It is still off by default. Resolution reads the keychain back where it would read the cache. The TUI's variable form has the same toggle on Ctrl-S (shown as [x] save→keychain); a run started from it passes the ticked names on.
  • If the keychain can't be read (locked, access denied), the variable is asked for again as if nothing were stored, and the reason is shown next to the field (web, TUI), on stderr (meshfox run), and in a "missing required variable(s)" error. That holds for a variable with a default= too: a secret whose stored value couldn't be read is asked for (the default only pre-fills the prompt), never silently replaced by its default.
  • Ticking "save" is a request, not a hint: if the store refuses the write, the run doesn't start and says why.
  • A [[env]] section may declare secrets = ["DB_PASSWORD"] beside vars. The names are in the file; the values are in the keychain, filed under the section's scope (global, global:<path= as written> or project:<canvas_root>). They take the same place in the precedence chain as a vars entry and get the same "project"/"global" badge.
  • meshfox prune [DIR] [--recursive] [--yes] — finds the regenerable files meshfox leaves behind and, with --yes, removes them; without it, only lists them (and exits 0). Three kinds, all rebuilt by running the canvas again: tangled files (.meshfox-tangle-<canvas>__<block>.<ext>, see tangle under "Runnable code fences") whose block is gone from a canvas that still exists, or whose canvas is not among the markdown files of the scanned directories; environments — .meshfox/<canvas file>.venv and .meshfox/<canvas file>.node — whose canvas file is gone; and stale temp files, meshfox-<pid>-<n>.<ext> in the system temp dir older than a day (what a killed run leaves). DIR defaults to the current directory; --recursive also walks subdirectories, never through a symlink and skipping .git, node_modules, target and .meshfox. A canvas with a running worker is skipped and reported. Never touched, and never offered: .meshfox/<canvas>.session.sqlite3 (run history) and .meshfox/<canvas>.env (variable cache, may hold secret values) — those are deleted by hand. Each removal is re-checked against the kind of file it claims to be, and a symlink is refused. It runs in the CLI's own process, not in a worker, since it spans every canvas in a directory, including ones that no longer exist. Secrets in the system store are meshfox secret prune's.
  • meshfox mv SRC DST [--dry-run] — moves a canvas file or a whole directory of canvases together with what meshfox keeps for them. For a file, the .meshfox/<canvas file>.env variable cache and .meshfox/<canvas file>.session.sqlite3 run history (with its -wal/-shm companions) are renamed with it, and a leftover .worker.lock is dropped; a directory is moved as one, .meshfox/ included. With secret_store = "keychain" the saved answers are filed under the canvas's absolute path (doc:<path>, and an [[env]] section's under project:<directory>), so they are copied to the new scope first, the files move, and only then are the old ones deleted; if the move fails the copies are removed again. (Plaintext answers live in the .env file and just move with it.) The .venv and .node environments hold absolute paths, so they are deleted, not moved — the next run of the setup block builds them again. Nothing is overwritten: DST must not exist (unlike mv(1), an existing directory is not moved into), its directory must, and a directory cannot go inside itself. It refuses while a worker is running for any of the canvases. Other canvases under SRC's parent directory that seem to refer to the moved one, and the relative links of a file moved to another directory, are listed with their line numbers but never rewritten. --dry-run prints all of this and moves nothing. Across filesystems it copies and then deletes. Like prune, it runs in the CLI's own process.
  • meshfox env list [CANVAS|DIR] [--recursive] and meshfox env rm CANVAS [--name NAME] [--python | --node] — the environments @python_venv (.meshfox/<canvas file>.venv) and @node_env (.meshfox/<canvas file>.node) build, with the named ones (.named/<name>) inside them. list shows each with its kind, name= and size, and marks one whose canvas file is gone. rm deletes them for a canvas that still exists — the case prune leaves alone — so the next run of the setup block builds them again, e.g. after a system Python upgrade (run it with meshfox run --fresh: the block is usually cached and deleting its directory does not make it stale). Without --name it removes the usual environment and every named one; --name removes just that one (the name must match the name= option's shape, so it cannot leave .named/); --python/--node limit the kind. It refuses while a worker is running for the canvas, and when there is nothing to remove, and never follows a symlink. Run history and variable caches are not touched. Like prune, it runs in the CLI's own process. The MCP server has env_list and env_rm for the same.
  • meshfox secret set|show|rm NAME [--canvas F | --global [--path P] | --project DIR] manages values; show prints one only with --reveal.
  • The keychain can't be enumerated, so every save (checkbox or secret set) is also noted — scope and name, never the value — in a local index, ~/.meshfox/secrets.sqlite3. meshfox secret list [--json] reads it and gives each entry a status: ok; orphan-decl (the canvas no longer declares the variable as secret, or the config no longer lists it in secrets); orphan-path (the canvas file or project directory is gone — a move or rename looks the same); unknown (a file that should say couldn't be read or parsed — never treated as an orphan). meshfox secret prune shows the orphans and, with --yes, deletes them from the keychain and the index. The MCP server's secret_list returns the same list (never values), limited to canvases and projects under its root plus global entries. Secrets saved before the index existed, or by other means, aren't in it.

A value written by hand always wins over the store: a vars entry in the same section, or a line in the canvas's .meshfox/<file>.env. Turning the keychain on doesn't take away the plaintext option.

Configure. With secret_store = "keychain", meshfox configure, the TUI's c and the web "configure" button also list the document's secret variables (with plaintext they stay out, as configure would only write them into the cache file). The value is never shown or read back into the form; each field says what's there instead: stored in keychain, set by project/global config — type a value to override (the same badge as other inherited values), or not set. Typing a value saves it to the store; leaving it empty changes nothing. A stored value can be deleted from the TUI (Ctrl-D) and the web form ("clear it"); the CLI uses meshfox secret rm. Over HTTP, GET /api/vars/configure adds stored to a secret's status and POST /api/vars/configure takes secrets: {NAME: value} and clear: [NAME] beside vars.

Not implemented yet: other platforms, and managing the [[env]] secrets entries (global/project scope) from the web UI/TUI — see TODO.canvas.md.

Computed variables (from=)

A meshfox:var with from= (see above) never goes through the override/env/cache/default/prompt chain the rest of this section describes — its value is computed, by running its from= block first and reading back what that block produced:

<!-- meshfox:var name="RESOURCE_ID" from="provision/create" -->

```bash name="create"
id=$(some-tool create-thing)
echo "RESOURCE_ID=$id" >> "$MESHFOX_VARS_OUT"
```

```bash name="deploy" env="$RESOURCE_ID"
echo "deploying $RESOURCE_ID"
```
  • Why not just read the block's own environment? A process's environment is gone the moment it exits, regardless of what language it was written in — there's no portable way to peek at a finished child's env from outside. Instead, whenever a block being run is a from= target for some declared variable, meshfox hands it a fresh, empty file and tells it where via the MESHFOX_VARS_OUT environment variable — the block is expected to append NAME=value lines to it (same KEY=value-per-line format the var cache uses) before exiting. A temp file, not a pipe: no mkfifo/blocking-open deadlock risk, no kernel pipe-buffer size limit, and it works the same on every platform meshfox runs on. This needs zero per-language support in meshfox itself — the same reason CI systems facing the identical problem (GitHub Actions' $GITHUB_OUTPUT, etc.) converged on the same shape. A block that isn't a from= target for anything never sees MESHFOX_VARS_OUT at all.
  • Only trusted on a 0 exit. A nonzero exit fails the run the same way any other step's nonzero exit does — whatever the block wrote (or didn't write) to its vars-out file is never read. A 0 exit that didn't produce a value for some variable declared from= it is also a hard failure, not silently treated as "still missing" — a computed variable is never prompted for, so there'd be nothing else to fall back to.
  • Ordering. A block's from= target is an implicit dependency, exactly like an explicit deps= entry — running (or resolving the chain for) any block whose env= references a from=-declared variable automatically runs that variable's source block first. Unlike deps=, this edge is never skipped by --no-deps/the web UI's plain "run" (as opposed to "run chain") button: a deps= dependency might already have fresh cached output, a legitimate reason to skip rerunning it, but a from=-declared variable has no value at all until its source runs, so skipping that edge is never a meaningful choice.
  • Never user-suppliable. --set/a submitted web form/the process environment/the on-disk cache can never resolve a from-declared variable, even if they name it — only an actual run of its from= block can, so a stale or hand-typed value can never impersonate a computed one. Consequently meshfox configure and the web UI's pre-run vars form never offer a from-declared variable as a field.

Dynamic default/choices (default_var=/choices_var=)

default_var=/choices_var= (see above) let one variable's default/ choices come from another declared variable's resolved value instead of a literal string — and since that other variable can itself be from=-computed, this is how a select's options (or a text field's suggested default) end up actually coming from running a script:

<!-- meshfox:var name="REGIONS_LIST" from="aws/list-regions" -->
<!-- meshfox:var name="REGION" type="select" choices_var="REGIONS_LIST" -->

```bash name="list-regions"
aws ec2 describe-regions --query 'Regions[].RegionName' --output text | tr '\t' ',' >> "$MESHFOX_VARS_OUT"
```

```bash name="deploy" env="$REGION"
echo "deploying to $REGION"
```
  • Implicit ordering, transitively. REGION here doesn't declare from= itself, but resolving it requires REGIONS_LIST to already be resolved — so a block referencing REGION via env= gets an implicit dependency on aws/list-regions (via REGIONS_LIST's own from=) the same way it would if it referenced REGIONS_LIST directly. This chains arbitrarily deep: a default_var/choices_var reference is followed transitively, in both dependency-ordering and "which variables does this block actually need resolved" scoping, wherever either matters.
  • Substitution, not delegation. Once REGIONS_LIST resolves, its value becomes REGION's own effective choices (split the same comma-separated way a literal choices= is) for exactly this resolution — REGION still goes through its own full override/env/ cache/default/prompt chain afterward, using that substituted value where a literal choices=/default= would otherwise sit. If the reference isn't resolvable yet (rare in practice, since the expected default_var/choices_var target is from=-computed and therefore already guaranteed resolved by the ordering above), the referencing variable is simply deferred rather than shown with stale or empty choices.
  • Mutually exclusive with the corresponding literal attribute (default/choices) and with from= — see each attribute's own entry above. meshfox validate catches a default_var/choices_var naming a variable nothing declares, and a reference cycle.

Choosing from a select, and choices that could not be computed

Every front end offers a select the same way, so typing the same text does the same thing everywhere:

  • A filterable list. Typing narrows the list to the choices that contain the text, ignoring case and surrounding whitespace (meshfox_core::filter_choices; the browser repeats the rule in web/src/choiceFilter.ts, and both are tested against one table, crates/core/testdata/choice_filter.json). The order stays the declared one. In the browser (ChoiceSelect) and the TUI the list opens from the field: ↑/↓ move and wrap, Home/End jump, Enter picks, Ctrl-U clears the text, Esc clears the text first and closes the list on the second press. The terminal prompt of meshfox run/meshfox configure lists up to 15 choices and asks for a filter beyond that: a number picks that row of the list as shown, an exact name picks it, any other text filters, and a single match is picked at once.
  • choicesPending. A select with choices_var= whose source variable has no value has no list to offer. The status of such a field (GET /api/vars, /api/vars/configure, /api/form/fields, POST /api/args) carries choicesPending: {"var": "<the choices_var variable>", "reason": "..."} instead of an empty choices. reason says why the variable was not computed when something tried to: the source block (or one of its deps=) exited non-zero (with the last lines of its output), could not be started, or running is switched off (--no-exec); it is absent when nothing tried. The web control is disabled and shows the sentence "No choices to pick from: its choices come from X, which could not be computed: "; the TUI shows (no choices: X has no value) on the field and the reason in the status line when it is opened; the CLI prints the same sentence. A run cannot go on past such a field (it names the reason), and configure leaves it out of what it saves rather than writing an empty value over a stored one.
  • The source runs with an empty environment, as above, apart from what the steps before it in its own deps= chain published: an interpreter="$PYTHON" that a setup block builds is expanded and works. A script that needs env= input of its own fails there — and the reason is how that shows.

Configured file access ([files] allow)

Every consumer of a document-authored local file reference uses FileAccess: file previews/tables, opening and running file nodes, includes, Markdown assets, image change tracking and static/PDF export. Besides the canvas's own directory and trusted dir/file variable values, it accepts explicit configuration grants:

[files]
allow = ["docs"]

In /work/esp32/.meshfox/config.toml, this grants /work/esp32/docs to nested canvases. /work/esp32/co2/README.canvas.md can reference ![SCD41](../docs/scd41-module.webp) without declaring a variable. The configuration file itself never implicitly grants its enclosing directory; allow = ["."] does so explicitly. Grant paths are relative to the owner of .meshfox, with absolute and ~/... paths also supported. The global file's owner is the user's home directory.

MESHFOX_TRUSTED_ROOT in the environment of a meshfox process names one more directory tree that process's canvases may read below, besides their own directory and the grants above. Like MESHFOX_READ_ONLY it is the process owner's to set (never a file's, so a canvas cannot widen itself by what it says), and every core the process starts inherits it. meshfox view PACK sets it to the directory it unpacked a pack into; it is read-only for meshfox, like every root.

The nearest explicitly configured allow replaces the entire inherited array, including with []; it does not revoke the canvas directory or independent variable grants. Missing config files contribute nothing. Unreadable or malformed configs and invalid files.allow values produce an access error, never a fallback to a broader ancestor. Entries must be nonempty strings. Grants are reloaded for each access, so revocation is immediate. Canonical-path confinement rejects .. or symlinks escaping all authorized roots.

Local Markdown URLs are transported in /api/asset?file=... query parameters so browsers cannot normalize away .. before validation; an include's optional dir= must match an actual resolved include asset base (or the main canvas directory). Both the API and legacy relative-file fallback apply the same FileAccess policy. Export copies external assets under namespaced relative output paths, never outside the output directory. These grants govern file access by meshfox, not the filesystem privileges of code blocks the user runs.

Directory variables (type="dir")

A file node's target is confined to the canvas's own directory tree (see "Node types"): a canvas file is untrusted input, so what it says must not widen what it can read. A type="dir" variable widens it by its value — and only a value the canvas text did not supply by itself:

<!-- meshfox:var name="WORK_DIR" type="dir" default="@tmp/work" -->
<!-- meshfox:var name="DATA_DIR" type="dir" default="~/datasets/parsed" -->

Every directory a resolved dir variable points to (and the canvas's @tmp, below) becomes a trusted root for that canvas: a file node's target may resolve inside it, in addition to the canvas directory. The check is unchanged — canonicalize, then require the result to lie under the canvas directory or a trusted root — so .. and symlinks cannot step out of a root any more than out of the canvas directory. Trusted roots are read-only for meshfox (it reads previews and tables there and never writes); what a block does there is up to the block.

Where the value comes from. Resolution is the ordinary one (override -> environment -> cache -> shared config -> default, see "Resolution") with one rule for the last step: a dir variable's default is taken silently only when it is @tmp or @tmp/.... Any other default — absolute, ~, ../x, or a relative path inside the canvas — behaves as required: the first time it is needed it asks for one explicit confirmation, with the default as the pre-filled suggestion; the confirmed answer is cached like any other and trusted from then on. A value from --set, the environment, shared config, the cache or a from= block is trusted as is (a person or a block they ran produced it). Until a variable is confirmed a target naming it fails with "$NAME is a directory variable that hasn't been confirmed yet" rather than "not found". Values are written the way a person writes them and expanded when used: @tmp[/...], ~[/...] and relative paths (against the canvas directory) become absolute; the cache keeps the literal text. meshfox configure prompts for a dir like any variable and rejects an existing non-directory (a missing path is fine) and @tmp/...

@tmp. The canvas's own temporary directory, <dir>/.meshfox/<canvas file>.tmp/, created on first use and owned by the worker. It is always a trusted root and needs no confirmation. It lives for the session: session reset (the web button, meshfox session reset, the MCP session_reset tool) deletes it together with the freshness records of the blocks that produced its content, so nothing claims to be fresh over files that are gone. GET /api/session/tmp reports how many bytes a reset would delete. For data that must outlive the session, confirm an external path instead of @tmp.

Using it in targets. A file node's target may start with @tmp or contain $NAME / ${NAME} for a declared dir variable — [events]($WORK_DIR/by_language/en.csv). Only names of dir variables are expanded; any other $ is literal. The expansion happens before the confinement check, which is then applied to the result.

Scope: a trusted root applies to the whole canvas, including a dir variable declared inside a node (it is not limited to that subtree). include nodes use the same FileAccess checks and $NAME expansion. Their relative targets resolve against the main canvas; relative assets in included Markdown resolve against the included file's directory.

File variables (type="file")

A type="file" variable holds the path of one file:

<!-- meshfox:var name="INPUT" type="file" ext="mp4,mkv,mov" -->
<!-- meshfox:var name="OUTPUT" type="file" mode="save" default="@tmp/out.mp4" -->
  • mode="open" (default) is an input: the file must exist and be a file. mode="save" is an output: only its parent directory has to exist, and the value must not be an existing directory. These are checked when a new answer is accepted (--set, a form, a prompt) and only for what is decidable without the canvas — an absolute or ~ path; a relative path or @tmp/... is taken as written.
  • ext="mp4,mkv" is a filter hint for the path picker. Nothing is validated against it.
  • Values are written the way a person writes them (@tmp/..., ~/..., relative to the canvas directory) and expanded to an absolute path when used; the cache keeps the literal text. For @tmp/dir/x.mp4 the worker creates dir/ — never x.mp4 itself.

Trusted root. Like a dir, a resolved file value is a trusted root for the canvas — but of exactly that file: a file node's target may resolve to it (and $NAME / ${NAME} expands for file variables as it does for dir ones, so [clip]($INPUT) previews the chosen file), while its siblings and the directory it lives in stay out of reach. The match is on the canonical path, never a prefix, so a value that is — or later becomes — a directory grants nothing. A mode="save" file that does not exist yet is a root once a block has created it.

The rules for where the value comes from are the dir ones: a default that is not @tmp[/...] behaves as required (one confirmation, then cached); a value from --set, the environment, shared config, the cache or a from= block is trusted as is. A default with a .. component under @tmp is not an @tmp path (for dir variables too) and needs confirmation the same way.

Date and datetime variables (type="date", type="datetime")

A date holds one calendar day, a datetime one instant:

<!-- meshfox:var name="SINCE" type="date" default="2026-01-01" -->
<!-- meshfox:var name="DEADLINE" type="datetime" prompt="Deadline?" -->
  • The stored value — what is cached, passed with --set, sent by a form and put into a block's environment — is always canonical. A date is YYYY-MM-DD and must be a real day (2026-02-30 is rejected, 2024-02-29 is fine). A datetime is RFC3339 with an offset, the same shape as the createdAt/updatedAt stamps (see "Timestamps"): 2026-10-25T14:30:00+03:00 or ...Z, a T between the parts, fractional seconds allowed. A datetime without an offset is not a valid value, so an instant is never ambiguous between machines.
  • Validation is the same strict check everywhere (--set, prompts, forms, API requests, block arguments, the environment, the cache, shared config, from= output, and a default on a meshfox:arg). There is no min/max, format=, or relative default such as today/now.
  • Interactive text fields are lenient and normalize what was typed into the canonical value: for a datetime, a space may replace the T, seconds are optional, and the offset may be Z, +03:00, +0300 or +03; when no offset is typed, the local offset of the machine the field is edited on at that wall time is attached (2026-10-25 14:30 → 2026-10-25T14:30:00+03:00). This applies to the CLI prompt and the TUI variable form and form fences; --set and the API do not guess.
  • Starlark has no datetime type; a block's value is the canonical string.

List variables (type="list")

A list holds an ordered list of strings. The variable does not say how a list looks — there is deliberately no separator attribute — because that depends on who reads it: a bash block wants shell words, a psql one a Postgres array literal. The producer decides how it writes the list, each consumer how it reads it.

<!-- meshfox:var name="PAGES" type="list" of="int" from="root/listing" -->
  • of="int" — optional item type (any scalar type except select; default string). Every element is validated against it, wherever the list comes from. With choices="us,eu" a list may only hold those elements (forms still show a list as a text field holding the JSON array; a multi-select is planned). A list cannot be secret, and it is not a valid type for a meshfox:arg (an argument is one value).

  • Writing a list. A from= source block (or any producer of the variable's value) writes to its MESHFOX_VARS_OUT file either one line per element,

    echo 'PAGES[]=3' >> "$MESHFOX_VARS_OUT"
    echo 'PAGES[]=Acme, Inc.' >> "$MESHFOX_VARS_OUT"
    

    (any characters but a newline, elements trimmed like every value in that file), or a JSON array on one line: PAGES=["3","Acme, Inc."]. Lines apply in order, a later plain PAGES= replaces earlier elements, and PAGES[]= after a JSON line keeps appending. default= takes the JSON array form (default='["amd64","arm64"]'). On the command line --set 'PAGES[]=3' --set 'PAGES[]=1' builds a list element by element and --set 'PAGES=["3","1"]' sets it outright.

  • Reading a list. In env= a format after | chooses the rendering for that block, per entry, so two blocks can read one list differently:

    env="PAGES|space"        # PAGES=1 'Acme, Inc.'
    env="IDS=PAGES|pg"       # IDS={1,"Acme, Inc."}
    
    formatresult
    (none), jsona compact JSON array ["1","Acme, Inc."] — lossless, the default
    linesone element per line
    spaceshell-quoted words separated by spaces, safe for eval and set --
    commaelements joined with , (no quoting)
    tabelements joined with a tab
    pga PostgreSQL array literal, quoted where Postgres requires

    An unknown format, or a format on a variable that is not a list, is a meshfox validate error. The format is part of the block's fingerprint.

  • Fan-out. name=*$LIST in a deps= binding (above) runs the dependency once per element. Several spreads in one application give their cartesian product, in binding-name then list order; repeated elements collapse into one application (identity is the canonical application address); an empty list gives no applications — the dependency simply disappears and the consumer still runs. A computed list (from=) that has not been observed yet keeps the edge pending, and the runner expands it as soon as the source finishes, like any other computed binding. The usual graph limits (128 nested, 4096 distinct applications) apply, and one spread may not create more than 4096. The expanded applications are ordinary steps, and the executor runs one step at a time: they run one after another in list order (the list 3 1 2 runs cut[page=3], cut[page=1], cut[page=2]), and a failing element stops the chain like any failing step. Each application has its own history, locks and logs. A static check (meshfox validate) knows no values: it checks that the list is declared with type="list" and that its item type fits the argument, and expands nothing.

A map is therefore a producer that lists, a parameterized block that handles one element, and a consumer whose deps= spreads over the list; the consumer receives the list itself (env="PAGES|space") to reduce over the per-element results it finds in files:

<!-- meshfox:var name="PAGES" type="list" of="int" from="root/listing" -->
```bash name="listing"
for p in 3 1 2; do echo "PAGES[]=$p" >> "$MESHFOX_VARS_OUT"; done
```
<!-- meshfox:arg name="page" type="int" -->
```bash name="cut" outputs="out/page-${page}.txt"
pdftotext -f "$page" -l "$page" doc.pdf "out/page-$page.txt"
```
```bash name="merge" deps='cut[page=*$PAGES]' env="PAGES|space"
for p in $PAGES; do cat "out/page-$p.txt"; done > all.txt
```

TUI select search

The Choose overlay in inline and modal forms filters options as you type, using a case-insensitive Unicode substring match and preserving option order. The search row shows the query and matching/total counts; matches are highlighted. Up/Down, Home/End navigate the filtered list; Enter or a mouse click confirms only the selected value. Letters including j/k are search input. Backspace removes a character, Ctrl-U clears the query. Esc clears a nonempty query, then closes on a second press without changing the field. An empty result shows No matches; Enter does nothing. Filtering preserves the highlighted option when it still matches, otherwise highlights the first result.

Date pickers

A date/datetime field is an ordinary text input that can also be filled from a calendar. Typing the value by hand always works.

  • Web. The browser's own <input type="date"> is used for a date. A datetime is <input type="datetime-local"> (seconds included) next to a small editable UTC-offset box (+03:00, Z). A first pick takes the browser's offset at that time; the two are combined into the canonical RFC3339 value, and a malformed offset leaves the value empty (rejected on submit) rather than guessing.
  • TUI. Ctrl-O on a focused date/datetime field of the variable form or a form fence opens a calendar modal over it, starting from the field's value (or now). The field ends in a clickable [Pick… ^o] marker, as dir/file fields end in [Browse… ^o]. Keys: ←/→ move a day, ↑/↓ a week, PageUp/PageDown (or [/]) a month, Home/End the first/last day of the month, t today, Enter choose, Esc cancel; weeks start on Monday. For a datetime the modal also has hour, minute, second and offset fields: Tab/Shift-Tab (or ←/→ outside the calendar) move between them, ↑/↓ change a field by one (the offset by 15 minutes, within −12:00…+14:00), PageUp/PageDown by ten, n sets now. A click on a day selects it (a second click on the selected day chooses it), the arrows in the month header and the wheel page months, and a click on a time field focuses it.

Path pickers

A dir or file field is rendered as a path field with a Browse action wherever a variable is asked for — the web variable forms and form fences, the TUI variable form. The picker lists directories through the worker (the file system it browses is the one blocks run against, and the worker may not be where the UI is):

GET /api/fs/list?path=<path>&kind=dir|file&hidden=true|false

path is @tmp[/...], ~[/...], absolute or relative to the canvas directory (empty: the canvas directory). A file path lists its directory and reports the file as selected; a path that does not exist lists its nearest existing ancestor. The response carries path, parent, home, canvasDir, entries (name, isDir, size; directories first) and truncated. Only names are returned, never content, and the browsable root is the whole file system. Browsing grants nothing: a path becomes a trusted root only as a variable value, by the rules above.

In the TUI the picker opens with Ctrl-O on a focused dir/file field (of the variable form or a form fence) and lists through the same endpoint — or straight off disk when no worker is running. Keys: ↑/↓/j/k move, Enter/→ descend into a directory (or choose a file; for a mode="save" file it fills the name line), Backspace/← go up, / filters, . toggles hidden files, e toggles the ext= hint, ~ and c jump to the home and canvas directories, s or Space chooses the listed directory (a dir picker), Tab focuses the name line of a save picker, Esc cancels. A click selects a row; a second click on it activates it. So the keys can be found without knowing them, a dir/file field ends in a clickable [Browse… ^o] marker (right-aligned in the variable form, after the value in a form fence).

The endpoint answers only requests whose Host is a loopback name and whose Origin, if any, is the server's own — a page from another origin open in the same browser cannot read a listing.

CLI

  • meshfox configure [canvas] — the one place that does walk every declared non-secret, non-session variable in the whole document, regardless of which (if any) block currently references it — showing its currently-resolved value (cache / env / default) as the prompt's own default, and writing whatever you answer (even if unchanged) back to the cache. The explicit "set these up now" step, the same role ccmake/cmake -L plays for a CMake cache; nothing else in meshfox requires running it. Like every other command it goes through the canvas's worker (started if needed): the list of questions comes from GET /api/vars/configure, the same one the web UI and the TUI use, so a select with choices_var= is asked with its real, computed choices, and the answers are written back with POST /api/vars/configure — into the worker's own cache, which later runs read. There is no offline mode. Requires a terminal; refuses to run (rather than silently do nothing or apply bare defaults) if stdin isn't one. Secret variables are never shown here — asking for one that's never cached and immediately discarded again wouldn't do anything useful.
  • meshfox run only ever resolves/prompts for a variable that some block in the requested chain actually references (no separate configure step required, and no prompt at all for a block whose env= is empty or already fully resolved) — but it does so for the whole resolved chain up front, before running any of it, not block by block as each one's turn comes up. Without this, a variable only a block near the tail of a long chain references (e.g. a database password only the final load step needs) would only be asked for after everything ahead of it had already run — the same "resolve the whole chain's variables before starting" the web UI's own pre-run form (GET /api/vars) already does. A from=-computed variable is the one exception: nothing has run yet at preflight time, so it can't be resolved that early — it's still checked right before the block that needs it runs, once its source block has actually had the chance to produce it. --set NAME=value (repeatable) supplies overrides on the command line, the non-interactive equivalent of answering a prompt — the same flag CI would use in place of a TTY, which run otherwise requires whenever some referenced variable is still missing after --set/env/cache/default. A --set value is saved to the cache regardless of whether anything in the current invocation actually references it, same as cmake -D always updating CMakeCache.txt.

Options

Document-wide settings that flip a default behavior for the whole canvas — distinct from meshfox:var (a value asked for from whoever runs the document, and — unlike an option — declarable per-node too, see above) in that an option has no prompt and no value: it's either declared or it isn't. Declared as <!-- meshfox:option name="..." --> comments, only inside the root node's own body — an option is always document-wide, never per-node, so unlike meshfox:var it has no node-scoped form at all. A meshfox:option found in any other node, one missing name, or the same name declared twice, is a meshfox validate error.

<!-- meshfox:option name="unfold" -->

Currently defined options:

  • unfold — the web UI's default is every node folded to a compact title-only chip except the root, so a large canvas opens navigable rather than as a wall of expanded nodes; declaring unfold flips that default to everything expanded. A single node can still override whichever default applies to it with its own fold= attribute (see "File structure" above) — the option only sets what an unset node falls back to.
  • auto-timestamps — opts the whole document in to insert_child_node/ set_node_body's automatic createdAt/updatedAt stamping (see "Timestamps" above). Off by default — meshfox is first and foremost a documentation format, and most documents don't want bookkeeping churn on every regeneration (a cached block's output changing on every doc build, say, the way this project's own CLI.canvas.md's does). Worth turning on for a document that's genuinely a living record instead — a personal task tracker, a running log — where knowing when something was created or last touched is itself useful. Doesn't affect an explicit node meta --created-at — that works regardless, on any document.

An unrecognized name is not an error — options are meant to grow over time, and an older meshfox binary should still open a canvas written for a newer one, just without acting on whichever option it doesn't know about.

Hand-editing the comment directly always works, but the web UI's toolbar also has an "options" button that toggles a known option (currently just unfold/auto-timestamps) without touching the file by hand — it writes the same comment. An unrecognized declaration already in the file is left exactly as-is either way, whichever recognized ones are also toggled alongside it.

Not every per-block display default belongs here, though — see fold= on a runnable fence (distinct from meshfox:node's own fold=; "Runnable code fences" below) for one that's deliberately a per-block attribute instead of a document-wide option: a block's own source being worth collapsing by default is a property of that one block (a form/autorun pair whose own result table is the point, say — see "Form fences" above), not something a whole document should default for every block at once.

Tag colors

A node's color= (a JSON-Canvas preset "1"-"6" or a literal #rrggbb hex string) is normally set per node. meshfox:tag-color declares a document-wide default instead: any node carrying a given tag, with no color= of its own, picks up that tag's color automatically. Same placement restriction as meshfox:option (root-only, unlike meshfox:var, which also allows a node-scoped form — see "Variables" above) — and the same reasoning: the default applies to the whole document, not one node.

<!-- meshfox:tag-color tag="bug" color="1" -->
<!-- meshfox:tag-color tag="feature" color="4" -->

One declaration per tag rather than one comment listing every tag — a tag name may contain spaces or other characters a bare key="value" token can't safely stand in for, so tag= and color= each get their own quoted value.

Precedence, checked in this order:

  1. The node's own explicit color=, if it has one — always wins.
  2. Otherwise, the color declared for the first of the node's own tags (in the order written on that node) that has one.
  3. Otherwise, no color — same as today.

A meshfox:tag-color missing tag= or color=, or the same tag declared twice, is a meshfox validate error — every other reader (run/view/tui, the server) falls back to no tag-derived colors at all rather than breaking, same best-effort split meshfox:option above has between "parses enough to view" and "fully valid".

Cached output

Running a cached block writes/updates a fenced block immediately after the source, wrapped in markers so re-runs replace just that region:

```bash name="build" cache
cargo build --workspace
```
<!-- meshfox:output name="build" hash="a1b2c3d4" -->
```text
exit code: 0 · 4.2s
...
```
<!-- /meshfox:output -->

When the worker saves a canvas, it removes a complete output region immediately following a source fence without cache (also when cache=false). This applies to both text and Markdown output, across the entire saved document; no rerun is needed. Reading alone leaves the document unchanged. Incomplete output regions are left untouched. Output for fences with cache remains intact.

The header line inside the text fence carries the exit code alongside how long the block's own process actually ran (meshfox_core::format_duration_ms — "842ms"/"2.3s"/"1m 05s"), so a re-opened canvas still shows the last run's duration, not just whether it succeeded. The web UI shows the same figure live, ticking up in real time while a block is still running (like a Livebook cell) rather than only once it's done.

With output="markdown" on the fence (see "Runnable code fences" above), the same region instead carries the captured stdout spliced in as real Markdown, with no wrapping fence and no exit code/duration header on a successful run:

```python name="df" cache output="markdown" interpreter="python3 -u"
print(df.to_markdown())
```
<!-- meshfox:output name="df" hash="a1b2c3d4" -->

| id | name |
|---:|:-----|
|  1 | ann  |

<!-- /meshfox:output -->

— which renders as an actual table instead of preformatted text. Any stderr the block produced comes first, as its own ordinary ​```text​ block — same shape the default (non-markdown) rendering always uses — regardless of where in the script's own execution order those lines actually landed relative to stdout: output="markdown" is about treating stdout as structured content worth parsing, and stderr (warnings, progress bars, tracebacks) was never meant to be part of that, so it's kept separate rather than interleaved into what gets rendered as Markdown. A failed run (exit_code != 0) still gets a leading bold **⚠ exit code: N · duration** line right before the stdout half, since the rendered content alone might not make that obvious. Because this content is genuinely re-parsed as Markdown/HTML by every downstream reader (unlike the fenced default, which is always inert), meshfox_core::output::write_output escapes any literal <!-- in it first (&lt;!--) so a command's own output can never forge a meshfox:node/meshfox:edge/meshfox:output (or any other meshfox:*) comment — and a fence inside the region (a forged ```bash name="..." cache block, say) is never picked up as a real runnable or ```starlark constraint block either, the same way heading/meshfox:node detection (crate::mdcanvas::scan) treats the whole marker-to-marker region as opaque. output="markdown" is therefore meant for output you trust to look at, not output from an untrusted source — same trust level as any other Markdown already committed to the file.

Image output (output="image")

For a block whose stdout is a picture — dot -Tsvg, plantuml -tsvg -pipe, typst compile --format svg - -, mmdc -e svg, vl2vg | vg2svg, a Python script that prints fig.savefig(buf, format="svg") — meshfox wraps the SVG into the one Markdown line every renderer already shows, so no script has to base64-encode anything:

```bash name="g" cache output="image" output-attrs="bg=#fff"
dot -Tsvg graph.dot
```
<!-- meshfox:output name="g" hash="a1b2c3d4" -->

![g](data:image/svg+xml;base64,PHN2Zy…){bg=#ffffff}

<!-- /meshfox:output -->

The region has exactly the shape output="markdown" writes (any stderr as its own leading ```text block, a leading bold **⚠ exit code: N · duration** line on a failure), and is the same line a script could print by hand under output="markdown" (see examples/pandas-dataframe.canvas.md); the image's alt text is the block's name. Only a successful run whose stdout is an SVG (meshfox_core::svg::looks_like_svg: an optional XML prolog/comments/doctype, then a root <svg) becomes an image. Anything else — a failed run, a tool that printed an error message — is stored as what it printed, in a plain (info-string-less) fence, never as a broken image and never parsed as Markdown. Only SVG is recognised: stdout is captured line by line as text, so a binary format (PNG) can't pass through it; print a data:image/png;base64,… Markdown image under output="markdown" for those.

In the TUI and the web UI, the same conversion is applied to a finished run's live output, so a block shows its picture right after it runs, before anything is saved.

See examples/svg.canvas.md for runnable variations.

The marker's own hash= is a short fingerprint (meshfox_core::fence::fingerprint) of everything about the fence that actually changes what running it does — its code, lang, interpreter=, and its env=/deps= references (by name, not a resolved value) — not a security hash, just a cheap way to tell "this output is still current" from "the fence changed since this ran": every reader (the web UI, the TUI) recomputes the fence's own live fingerprint and compares it against this stored one, showing the cached output as stale (still there, never silently discarded) whenever they differ, until the block is actually re-run. A marker written before this field existed has no hash= at all — treated the same as stale, since there's nothing to compare against.

Output (live or cached) that contains ANSI SGR color/style escape codes renders in color in the web UI and TUI, both while a block is still streaming and for a previously-cached block's saved output. The TUI supports standard, 256-color and RGB foreground/background colors, bold, dim, italic, underline, reverse, hidden and strikethrough styles, including styles spanning lines. ANSI colors also render in the TUI output console, service logs and run history; cursor movement and other terminal controls are ignored. Commands run without a pseudo-terminal, so a tool that auto-detects "not a real terminal" and disables its own color output (cargo, git, plain ls) prints plain text here unless it's told to force color (--color=always, or a script emitting raw escape codes itself).

Comments

<!-- meshfox:comment -->Any text<!-- /meshfox:comment -->

meshfox:comment//meshfox:comment are, by themselves, nothing but two ordinary HTML comments — invisible to any Markdown renderer, meshfox included. The text sitting between them, though, is completely ordinary Markdown as far as a plain renderer (GitHub, a text editor's preview, ...) is concerned, so it renders there like any other paragraph — but meshfox's own tooling (the web UI, the TUI, static/pdf export, meshfox run's prompts) recognizes the marker pair and drops the whole region, markers and text alike, before a node's body ever reaches any of them. A node's raw Markdown on disk always keeps the region intact either way; only what meshfox itself shows strips it.

That makes it the place to put context meant only for someone reading the raw file outside meshfox — most commonly a short "this is a meshfox document" note, since meshfox's own UI already makes that obvious just by existing. meshfox create (and meshfox view --create) writes exactly that as the new file's root body:

<!-- meshfox:canvas -->
# My Project

<!-- meshfox:comment -->
> This is a [meshfox](https://meshfox.orofarne.net/) document — open it
> with `meshfox view` (or `meshfox tui`) for the interactive canvas.
> This note is only visible here, in a plain Markdown viewer.
<!-- /meshfox:comment -->

A file/link node's body rule ("starts with exactly one Markdown link") is checked after stripping — a comment-wrapped blurb alongside the link doesn't count against it. Fence-aware, same as heading/node-comment scanning elsewhere in this spec: a marker written literally inside a code fence (e.g. showing someone this exact syntax) is left alone rather than treated as a real region.

Formal grammar

Reference EBNF for every meshfox:* construct described above, collected in one place. This is documentation only — kept in sync by hand with the actual parsers (crates/core/src/{mdcanvas,vars,options,tag_colors,fence,attrs,comment,output}.rs and the mirrored bits of the web/TS side), not generated from either one or used to generate either one. The syntax is simple and stable enough (single-line constructs, no nesting) that a parser generator would only buy correctness on the Rust side, while the web/TS side still needs its own hand-written reader regardless — the same reasoning already applied to future extensions. Treat a mismatch between this grammar and the code as a bug, usually in this grammar rather than the code.

Notation: ::= defines, | alternation, [x] optional, {x} zero-or-more, 'x' a literal, <x> a prose-described terminal.

Lexical building blocks

Shared by every construct's attribute list (crates/core/src/attrs.rs):

attr-list   ::= { ws attr }
attr        ::= key '=' value | key
key         ::= key-char { key-char }
key-char    ::= <any character except whitespace, '=', '"'>
value       ::= '"' { <any character except '"'> } '"'
              | "'" { <any character except "'"> } "'" | bare-value
bare-value  ::= <one or more characters, none of them whitespace>
ws          ::= <one or more whitespace characters>

A bare key with no =value is a flag, equivalent to key="true". An unquoted value can't itself contain whitespace — there's no escape for that, only wrapping it in "..." instead. Attribute order is never significant; the same key written twice keeps whichever occurrence the tokenizer's map-insert resolves to last (last write wins) — not itself a parse error, though a specific construct's own semantic rules below may still reject the result.

Markers

Every meshfox:* construct is an HTML comment, matched line-by-line, and fence-aware: a marker written literally inside a code fence (a documentation example, cached output that happens to contain one, ...) is never treated as a real one. That scanning rule is a document-structure property, not part of any single line's own grammar, so it's stated once here instead of being repeated per construct below.

node-marker      ::= '<!--' ws 'meshfox:node' attr-list ws '-->'
edge-marker      ::= '<!--' ws 'meshfox:edge' attr-list ws '-->'
canvas-marker    ::= '<!--' ws 'meshfox:canvas' ws '-->'
var-marker       ::= '<!--' ws 'meshfox:var' attr-list ws '-->'
arg-marker       ::= '<!--' ws 'meshfox:arg' attr-list ws '-->'
option-marker    ::= '<!--' ws 'meshfox:option' attr-list ws '-->'
tag-color-marker ::= '<!--' ws 'meshfox:tag-color' attr-list ws '-->'
output-open      ::= '<!--' ws 'meshfox:output' attr-list ws '-->'
output-close     ::= '<!--' ws '/meshfox:output' ws '-->'
comment-open     ::= '<!--' ws 'meshfox:comment' ws '-->'
comment-close    ::= '<!--' ws '/meshfox:comment' ws '-->'

ws around the tag name/--> above may match zero characters in practice (the parser trims, it doesn't require padding) — written as ws rather than [ws] only to reuse the same rule name as attr-list.

Attribute vocabularies

meshfox:arg declarations accept name, type, choices, choices_var, default, prompt, and required; duplicate and unknown argument attributes are signature errors. Their adjacency and local scope are specified in "Block arguments and applications" above.

Each construct restricts attr-list (above) to its own known keys — the vocabulary meshfox validate's unknown_*_attr checks enforce, though every other consumer (run/view/tui, the server) keeps silently accepting an unrecognized key, for forward/backward compatibility across format versions (see "Options" above).

node-attr      ::= 'id' | 'type' | 'x' | 'y' | 'w' | 'h' | 'color'
                 | 'tags' | 'parent' | 'fold' | 'edgeLabel' | 'edgeLabelAt'
                 | 'edgeSourceSide' | 'edgeTargetSide' | 'edgeVia' | 'display'
                 | 'lang' | 'interpreter' | 'preview'
edge-attr      ::= 'from' | 'label' | 'labelAt' | 'color' | 'style' | 'arrowStart'
                 | 'arrowEnd' | 'tags' | 'sourceSide' | 'targetSide' | 'via'
var-attr       ::= 'name' | 'type' | 'prompt' | 'default' | 'default_var'
                 | 'choices' | 'choices_var' | 'secret' | 'required'
                 | 'session' | 'from'
option-attr    ::= 'name'
tag-color-attr ::= 'tag' | 'color'
output-attr    ::= 'name'

display/lang/interpreter/preview only mean something when type is file/link (see "Node types"); meshfox validate enforces that cross-attribute constraint separately — this grammar only fixes the set of keys a line may use, not which combinations of them make sense together (same for every other "mutually exclusive with..." rule described in prose above, e.g. default/default_var/from on a meshfox:var).

Fence info strings

A code fence's info string (the text right after the opening ```) uses the same attr-list grammar, with the fence's language as an unnamed leading token instead of a key=value pair (crates/core/src/fence.rs):

fence-info      ::= lang [ ws attr-list ]
lang            ::= bare-value

runnable-attr   ::= 'name' | 'cache' | 'default' | 'deps' | 'env' | 'tty'
                 | 'autoclose' | 'service' | 'autostop' | 'always' | 'interpreter'
                 | 'autorun' | 'render' | 'output' | 'send' | 'reset' | 'fold'
                 | 'inputs' | 'outputs' | 'lease' | 'defer' | 'tangle' | 'extends'
constraint-attr ::= 'constraint' | 'name'
starlark-render-attr ::= 'render' | 'name' | 'output'

A runnable fence additionally requires lang to be bash or sh, or its own interpreter= attribute set (or an extends= to take one from) (see "Runnable code fences") — button and form (see their own sections above) are runnable-addressable the same way without either, having no real code of their own to require a language for in the first place. A constraint fence requires lang = 'starlark' and the bare constraint flag (see "Constraint fences") — again, cross-cutting rules enforced by the fence scanner/meshfox validate, not expressible in fence-info alone. send=, cancel= and reset only mean anything on a lang = 'form' fence, same as autoclose only meaning anything on a tty one.

A form fence's own body (unlike every other runnable fence's, which is the code/caption itself) is a separate line-based grammar, not Markdown or code:

form-body  ::= { ws } [ form-line { '\n' { ws } form-line } ] { ws }
form-line  ::= field-line | text-line
field-line ::= 'field' ws attr-list
field-attr ::= 'var' | 'label' | 'keep'
text-line  ::= 'text' ws ( string | <rest of the line> )

A blank line between form-lines is allowed (and ignored); any other non-blank line is a parse error. A text-line's string is a JSON string literal; unquoted, the rest of the line is the text.

Values with their own inner structure

A handful of attribute values are themselves small comma-separated grammars, layered on top of value (above) rather than on attr-list itself:

tag-list     ::= tag { ',' tag }
tag          ::= <one or more characters, none of them ',' or '"'>

deps-list    ::= deps-entry { ',' deps-entry }
deps-entry   ::= [ node-id '/' ] block-name [ '[' bindings ']' ] [ '!' ]

env-list     ::= env-entry { ',' env-entry }
env-entry    ::= [ '$' ] var-name
               | local-name '=' [ '$' ] env-source
env-source   ::= var-name | name-template
name-template ::= { name-literal | '${' arg-name '}' }
name-literal ::= <one or more ASCII letters, digits, or underscores>
arg-name     ::= <[A-Za-z_][A-Za-z0-9_]*>

A name-template must contain at least one argument placeholder. Its concrete result must be a valid declared variable name; the local alias must be a valid, non-reserved environment identifier. Template references use arguments only.

choices-list ::= choice { ',' choice }
choice       ::= <one or more characters, none of them ','>

tags= (on a node or edge), deps=, env=, and choices= (plus choices_var='s resolved value, split the same way) each use one of these — see each attribute's own entry above for what the pieces mean.

Ordinary Markdown (a node's own body) and Starlark (a constraint fence's own script) are each a complete grammar of their own — CommonMark and Starlark respectively — and out of scope here.

Minimal example

<!-- meshfox:canvas -->
# Hello Project
<!-- meshfox:node id="root" -->

Project root.

## Tests
<!-- meshfox:node id="tests" type="group" -->

### Smoke Test
<!-- meshfox:node id="smoke-test" -->

```bash name="smoke" cache
echo "hello from meshfox"
```

Addressed as: meshfox run tests smoke-test smoke (node-id path from root, then the block name).

CLI

  • meshfox run [--no-deps] <path...> <names> — run one or more comma-separated named blocks reached by walking node ids from the root. Each named block's deps chain runs first, automatically, in dependency order; a dependency shared by several requested blocks only runs once. --no-deps skips this and runs only the named blocks themselves, in the order given — the CLI equivalent of the web UI's plain "run" button next to "⛓ run chain". If a node has a default block (see "Runnable code fences" above — one explicitly flagged default, or one whose name already matches the node's own id), the trailing name can be dropped: meshfox run tests smoke-test addresses that node's default block directly, instead of meshfox run tests smoke-test <block-name>, tried first as an ordinary address and only falling back to this when that doesn't resolve, so it never changes the meaning of an address that already worked. Output prints line by line as the block produces it, not all at once after it exits — the exit code, printed last, genuinely isn't known any sooner. Ctrl+C kills whichever step is currently running (the whole process group it spawned, not just bash) and stops there; whatever earlier steps in the chain already completed stays cached on disk.
  • meshfox list — print every runnable block as an indented tree, each with its [button]/[cache]/[default]/[tty]/[deps: ...]/[env: ...] flags and a ready-to-paste meshfox run <path...> <name> — so you don't have to go spelunking through the file to find out what's runnable. A node whose only block is its default gets a single merged tree line instead of a separate one for the node and another for the block; a node with a default block and other blocks besides gets the node-id shortcut printed on its own header line instead, alongside its other blocks' own lines.
  • meshfox view [--port] [--no-open] — local web UI, read-only until "Edit" is clicked in the browser. A run's output streams into the browser live, line by line, as it happens, rather than appearing all at once when the block finishes; a running block gets a Kill button, for when one hangs. A tty block instead opens a real interactive terminal panel — see "Interactive (tty) blocks" above. A persistent activity strip stays visible beneath the canvas and console. It shows the current step's address and elapsed time, or Preparing / Between steps while no process is active. Clicking the address reveals and centers its node; Stop cancels that invocation. Concurrent runs expand into separate rows. Completion, failure, cancellation, and an unknown result after a broken connection remain visible with total elapsed time. The strip also offers Console, the checks summary, live terminal sessions, and service management; these global controls appear only in the bottom strip. Ordinary dependencies of a tty target stay on the canvas; its terminal opens only when that tty step starts.
  • meshfox validate — parse-only validation (single root, no duplicate ids, no dangling edges, type body rules, no dangling/cyclic deps=, no invalid deps= ? conditions); no execution, no writes. Exit non-zero on error — usable in CI/pre-commit.
  • meshfox check — run every embedded constraint fence's Starlark contract (see "Constraint fences" above) and report pass/fail per fence. Exit non-zero if the file fails to parse or any constraint fails — usable in CI/pre-commit alongside (or instead of) validate.
  • meshfox spec — print this specification.

list/view/validate/check (and every other subcommand with a positional slot free for it — configure/create/tui/static/pdf) take the canvas path either as an optional positional argument or via --canvas (the two are mutually exclusive — pick one). run and node <op>, whose own positional slot is taken by other arguments, accept only --canvas. Any command can also take it as a single leading argument before the subcommand itself — meshfox path/to.canvas.md validate — recognized by its .md suffix, since node ids never have one; that leading form is spliced into whichever of the two shapes the subcommand that follows actually expects, before it overrides the subcommand's own path/--canvas if both are given. Omit it entirely and any of them auto-discover the single *.canvas.md (or marked *.md) file in the current directory — except create, which always requires an explicit path, since there's nothing to auto-discover for a file that doesn't exist yet.

TUI command palette

Ctrl-p opens a modal command palette in the TUI's main view and in the fullscreen source editor (not over another modal or a focused text field). Typing fuzzy-filters the commands (case-insensitive subsequence, tighter matches first); Up/Down moves, Enter chooses, Esc steps back and then closes. Each command shows the key that does the same thing, if there is one.

In the main TUI view, Tab/Shift+Tab switch only between Tree and Document. Ctrl+W focuses Output, temporarily expanding it if collapsed; repeating Ctrl+W returns to the previous pane. From Output, Tab goes to Document and Shift+Tab to Tree. Fullscreen follows the focused pane. The palette offers the same console action. Forms and dialogs retain their own Tab navigation.

Read-mode text selection in Document and Output uses v to show a cursor. Arrows/hjkl, Home/End and PageUp/PageDown move it without selecting. Space anchors a selection; further movement extends it, Space clears it. Shift+arrows can also start selecting. y copies, Esc exits cursor mode. Mouse drag selects text and double-click selects a word. Selection survives opening the palette and freezes its own text while incoming output continues in the background. Soft wraps do not become copied newlines; code-frame borders and gutters are excluded. Its colors come from the theme's [editor] selection and fg pair. [tui] clipboard accepts "terminal" (default, OSC 52), "system" (native clipboard tools on the machine running the TUI), or "text" (ordinary terminal text, mouse reporting off, Enter/Esc returns). The palette offers the default copy action and each explicit method for one invocation; there is no automatic fallback. Table-cell y uses the same setting. The services shortcut is s; session reset is palette-only.

Main-view commands (not listed inside the editor) are the existing actions: run (with deps / only this block), kill, reset session (palette only), services (s), terminal sessions, document history, block run history, open file target, configure variables, edit source, search, fullscreen and collapse pane, help, quit. Each uses the same action as its key binding where one exists. Copy and reset session have explicit palette actions. "Block statistics" and "Canvas statistics" show the report meshfox stats prints (retained journals only) in a scrollable view; both need a worker. Block statistics pick their block like L (run history): the block selected in the Document pane while that pane is focused, else the selected node's only block, else a block picker. Canvas statistics cover the whole canvas.

Structure commands act on the selected tree row through the worker, the same operations as meshfox node and the browser's Edit mode: new child node, new sibling node, rename, change id, move to another parent, move up/down among siblings, add or remove an incoming edge, delete with its subtree, delete keeping its children. Title, id and delete confirmation are asked in the palette (prefilled where there is a current value); a parent or edge source is picked from a filterable list that leaves out the node itself and its visible descendants. A created node is selected once the reload brings it in. They need a worker and refuse a node that comes from an include.

From the source editor, structure commands run only on a saved buffer (otherwise the editor shows "unsaved changes"). The editor stays open: its buffer is replaced with the document as the worker now has it, with the cursor on the affected node. Editor commands (insert Unicode symbol, suggest attributes) are listed only inside the editor. Ctrl-Space (also received as Ctrl-@ or NUL) or Ctrl-t suggests attributes in the editor.

TUI source-editor Unicode palette

In the fullscreen TUI source editor, Ctrl-g opens a modal Unicode palette. An empty query shows a small built-in set of common symbols. Search matches English Unicode names case-insensitively (exact names first, then prefixes, then substrings); U+XXXX selects a code point directly. Up/Down moves the selection, Enter inserts the symbol at the cursor through the editor undo mechanism and closes the palette, and Esc cancels without changing the buffer. Invalid codes and queries without matches show an empty result; Enter then keeps the palette open. Control, whitespace and zero-width characters are excluded. The modal captures keyboard and mouse input so the buffer underneath cannot change while choosing. Glyph appearance depends on terminal fonts.

Local Markdown image refresh

The worker polls local Markdown image targets every 500 ms, including images in included documents and cached Markdown block output. Creation, modification, atomic replacement and deletion emit images-changed on /api/watch. WebUI invalidates local image URLs; TUI discards its image byte cache and fetches through the worker again. Reconnection also invalidates client image caches. Remote URLs and inline data: images are not filesystem watch targets. The same asset confinement rules apply to watching and serving images.

In the TUI, Markdown image percentage dimensions refer to the current document pane, preserving aspect ratio and fitting within its available area. For example, {width=100%} uses the full pane width when height permits; an omitted height does not impose the default thumbnail height. Images without percentage attributes keep a 56-column by 24-row maximum, limited by the pane size. Resizing or entering fullscreen recalculates these dimensions from cached bytes.

TUI document images render their visible rows when scrolled partially above or below the document pane, rather than disappearing when clipped. This applies to local images, inline images and link-preview images. Encoded row slices are reused during scrolling; iTerm2 slices retain dense SVG pixels and cell-based placement, while Sixel encodes and caches the exact visible pixel crop, including fresh raster dimensions and palette.

iTerm2 and Sixel always render a single cached visible fragment. Halfblocks and Kitty use native sliced rendering. There is no selectable image rendering mode. The fragment LRU cache holds at most 32 encoded views for iTerm2 or 128 for Sixel, and at most 16 MiB per image; image changes and viewport resizing replace it. A first visit encodes the fragment; repeated visits reuse the encoded PNG.

The TUI image protocol is configured by [tui] image_protocol in ~/.meshfox/config.toml and <canvas_root>/.meshfox/config.toml (local wins). Supported values: auto (default), iterm2, sixel, kitty, halfblocks. MESHFOX_IMAGE_PROTOCOL overrides both files; auto explicitly restores capability detection. The client fetches the public setting from /api/info when no env override is present. Invalid protocol values are reported at TUI startup. Nested canvases inherit the existing terminal Picker.

Run-scoped services and cleanup

autostop is a flag on a service fence (ordinary service or @service). It is off by default. An autostop service does not keep an otherwise idle worker alive. After the normal idle grace the worker awaits graceful service cleanup, then rechecks clients, runs and API activity before exiting. Failed cleanup remains visible and retryable and keeps the worker alive.

An @service with ready completes its chain step only after readiness succeeds. Nonzero readiness exits are retried at a short interval until start_timeout. Failure/timeout prevents consumers from running and cleans up a newly started service. Each new use of an already running service rechecks readiness; failure of that probe does not stop an independently started service. Health check continues to supervise the running service independently.

lease="database" on a runnable fence implies a service dependency. It holds that service for the entire root run, including cleanup. A lease declared by a checker dependency therefore lasts through its consumer's tests. Leases are collected even from dependencies whose bodies are skipped as fresh. Repeated references within the run share one lease. Nested native meshfox calls inherit the root lease scope, including across canvases.

If a lease starts a stopped service, the last lease release stops it gracefully. A service already running after an ordinary start is left running. An ordinary use of a lease-started service retains it beyond the leases. Explicit Stop wins over leases; a lease does not restart a service after Stop. Old cleanup never stops a replacement service instance.

defer="cleanup" on a runnable fence registers a cleanup block just before executing the fence body. Unreached or freshness-skipped blocks register nothing. Declare cleanup on the block that creates a resource, so even its partial failure triggers cleanup. Cleanup must tolerate partially created resources.

Registered cleanups execute after the run in reverse registration order, on success, failure and cancellation. All receive an execution attempt, even if a previous cleanup fails. They run without freshness skips and have an independent 30-second deadline. They inherit the registering block's explicit environment; the cleanup block's own environment uses the run's latest produced values and bound arguments. Cleanup addresses, inputs and confirmation requirements are checked before execution starts. A cleanup block cannot be interactive, a service, a form, or declare another defer. Its body runs directly; ordinary action dependencies are not executed as part of cleanup.

Leases are released after cleanup. The primary failure is preserved; cleanup errors are reported additionally. A cleanup failure makes an otherwise successful run fail. Cancellation is reported after cleanup completes. Worker SIGKILL cannot guarantee cleanup of external resources.

Calling another canvas

A native meshfox runnable fence invokes one application in the current or another canvas. It uses the ordinary runnable-fence naming/default rules, deps, inputs, outputs, cache, and run controls in CLI, TUI and the web UI.

```meshfox name="build"
canvas: ../../README.md
run: install-from-source
set:
  INSTALL_PATH: /usr/local/bin
fresh: true
```

The body is YAML with a strict schema. Specify exactly one nonempty run or stop address. Omit canvas to use the current canvas; when present it must be a nonempty string and resolves relative to the document containing the block, including inside an include. The target must be a canvas. run is one CLI application address (optional node path followed by an application), with shell-style quoting for address tokens, but no shell execution. Bind application arguments in this address, e.g. run: 'build install[DEST=local]'. Required arguments must be bound explicitly; missing child document variables are offered in the parent's normal pre-run form.

stop: database gracefully stops the addressed service regardless of leases, waits for cleanup, and succeeds if it is already stopped. It does not execute service dependencies. The target must be a service. Run options (set, fresh, no-deps, skip-fresh) cannot be combined with stop. Target confirmation is checked before the caller begins execution.

Optional fields: set maps child document-variable names to literal strings; fresh, no-deps, and skip-fresh are booleans with the same meaning as the CLI options. fresh conflicts with the other two. Unknown/duplicate fields and a confirm field are errors. Values are literal: no shell expansion, ~ expansion, or implicit parent-variable/argument interpolation. Native fences cannot carry env, interpreter, tty, service or tangle.

The target uses its existing worker, acquired through the coordinator or the normal per-canvas discovery lock. Without a coordinator, a quiet embedded child worker has the host process's lifetime. The child keeps its own working directory, variables, run locks, history and freshness records. Parent and child execution graphs are not merged. The parent invocation is always re-evaluated; its child's freshness options decide which child steps need execution. The parent may cache its latest displayed output but cannot skip checking the child solely because its YAML is unchanged.

Child input, configuration errors and confirmation requirements are checked before parent execution begins. Form names are scoped as @canvas[NODE:APPLICATION]/VARIABLE; CLI --set can supply the same names. Confirmation is per invocation and flows from the parent's explicit approval; there is no automatic approval option in YAML. Recursive calls are checked for cycles and limited to 32 nesting levels. Child tty applications are rejected during preflight; run them directly in their own canvas.

Output is streamed through the parent's ordinary retained run. Nested step banners show the child canvas and block; output events additionally carry optional child: {canvas, port, event} metadata. Child failure fails the parent step. Cancelling it signals the exact child run id and follows its terminal outcome, rather than killing the child worker or another run. Existing child services keep their ordinary worker ownership: cancellation does not stop all services in that canvas. When the child worker is embedded in the caller, calls starting services are rejected during preflight: open the target with meshfox view or use a coordinator first, so its services have an independent lifetime. Child service startup in an independent worker completes that chain step, without waiting for the background service's eventual exit.

Inline color spans

Pandoc-shaped bracketed spans attach colors to inline Markdown:

[Colored **text**]{color="#e06c75"}
[Highlighted text]{background-color="#fff3b0"}
[Both]{color="#123" background-color="#fff3b0"}
[Outer [inner]{color="#abc"} tail]{color="#e06c75"}

The attribute list must immediately follow ]. Only color and background-color are accepted, once each, with #rgb or #rrggbb (case insensitive). Values may be bare, single quoted, or double quoted. Unknown attributes, duplicates, empty lists and invalid colors leave the span syntax literal. Named colors and arbitrary CSS are not supported.

Content supports inline Markdown, links and nested spans. Inner colors override the corresponding outer color and restore it when closed. Spans stay within a paragraph, heading, or table cell. Code, images, HTML, and escaped opening brackets do not introduce color spans. WebUI, static HTML/PDF export and TUI support the same syntax; terminal color fidelity depends on terminal capabilities.

Starlark builders escape string content and accept existing inline fragments: md.color(value, "#e06c75") and md.background_color(value, "#fff3b0"). They return composable inline MarkdownFragment values, reject block fragments and invalid colors, and normalize colors to six hex digits.