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 byparent=(below).<!-- meshfox:node ... -->— right after a heading line. Turns the heading into a node and holds its bookkeeping askey="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.iddefaults 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 noidit is alwaysroot, whatever its title, so every canvas's root is addressable the same way and retitling it never changes its id.rootis reserved for it: ameshfox:nodeanywhere else withid="root"is an error (and a title-derived slug ofrootfor another node becomesroot-2); the root itself may still be given a different explicitid. Retitling a node whose id is derived from its title (a bare marker, not the root — nodes made bymeshfox 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/yare absolute document coordinates for every node except a direct child of agroupnode (see "Node types" below) — for a group member,x/yare relative to that group's ownx/yinstead (its own top-left corner), so moving the group moves every member with it without rewriting each one's stored position. A group's ownx/yis an optional anchor, draggable like any other node's — but itsw/hstay always derived from its members' own resolved boxes, never stored, even once it has an anchor.fold="true"orfold="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 withparent=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 ameshfox:edgedoes (below), so it lives here instead, on the child end: "the label of the edge that points at me". Unlike ameshfox:edge, a structural edge has no color/style/arrowhead attributes of its own.edgeSourceSide/edgeTargetSidechoose the departure/arrival side (left,right,top,bottom), andedgeVia="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'smeshfox:nodeline. 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. Besidesfrom, 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 owncolor),style("solid","dashed"— the default look when omitted — or"dotted"),arrowStart/arrowEnd("none"or"arrow"— an edge with neither set gets an arrowhead only atarrowEnd, matching the pre-styling default), andtags(comma-separated, same convention as a node's own).sourceSideandtargetSideoptionally 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; removingviarestores automatic routing.labelAtuses the same 0–1000 position along the rendered path asedgeLabelAt; omission places the label at the midpoint. All are omitted from the line unless explicitly set — a plainfrom="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*.mdfile 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 bymeshfox node add(and the web UI's "add child") at creation time, for a document that declaresauto-timestamps. Never rewritten afterward except by an explicitnode meta --created-at(for backfilling/importing existing data with a real historical date — works on any document,auto-timestampsor not).updatedAt— stamped automatically whenever a node's own body text actually changes:node body,node append, acached 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 unchangedcached 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-atflag 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 ownx/yis relative to the group's, not absolute — seex/yabove. -
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. Afilenode also accepts two optional display attributes on itsmeshfox:nodeline:-
display="link"(default),display="code"ordisplay="table"—codeshows 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/watchasfiles-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 oftype="dir"variables and the exact files oftype="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 theduckdbCLI reads), windowed so huge files stay usable. Requires theduckdbexecutable; see "Table previews" below. -
csv-strict="true|false"— CSV/TSV dialect validation for afilenode withdisplay="table"; defaulttrue. See "Table previews". -
lang="..."— syntax-highlighting language hint fordisplay="code"(e.g.lang="rust"). Optional; when omitted, the language is guessed from the target's file extension. Ignored whendisplayisn'tcode. -
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 againsttarget, making the node runnable:interpreter target(the target's path resolved relative to the canvas's own directory, confined to it — same boundarydisplay="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 atextnode's default code block. A runnable code fence (see "Runnable code fences" below) can carry the sameinterpreter=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)
linknodes don't supportdisplay/code/interpreter— their target is an external URL, not something meshfox reads from or runs off disk. Alinknode 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. Settingpreview=on a non-linknode is a parse error, same as an unknowntype=.Fetched over the network on first view and cached in memory for the life of the
meshfox view/meshfox tuiprocess — not persisted, not shared across processes, and never retried within that process once a fetch has failed. Since a canvas file'slinktargets are often attacker-controllable (the file itself may come from an untrusted source), the fetch is hardened against SSRF: onlyhttp/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 asfile/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/validatesee the bare link (and caption, if any), same asfile/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 whileimporting. A missingduckdbor an unusable cache is200withstate: "failed"anderror: {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}.viewis 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 a400with 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 formeshfox run/output caching; must be unique within its node. Normally required to make a fence runnable — except a node may have one fence with nonameat all, which is runnable too, implicitly named after its own nodeid. 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 letsmeshfox run/listskip 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 explicitnamehappens to already match its node'sid. -
confirm— optional flag (confirmorconfirm=true;confirm=falsedisables 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 requiresy(Enter/Escape cancel); CLI asks[y/N]in an interactive terminal and otherwise refuses, unlessmeshfox run --confirmis supplied. MCPrunacceptsconfirm: trueonly 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-depsexcludes 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 (cacheorcache=true); opts into persisting output back into the file. -
default— optional flag (defaultordefault=true); marks this fence as its node's default block — the onemeshfox run <path-to-node>addresses without a trailing block name (see "CLI" below). A fence whose (implicit or explicit)namealready equals its own node'sidcounts as default too, without needing this flag. At most one block per node may be default (explicitly, implicitly, or one of each) —meshfox validatereports 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) ornode-id/block-name(a block in another node, addressed by nodeidthe same waymeshfox:edge from=addresses nodes). Running a block always runs its full dependency chain first, automatically — each dependency's owndepsare resolved transitively, in order, with no block run twice even if several blocks in the chain depend on it. A cycle, or adepsentry naming a block that doesn't exist, is ameshfox validateerror. 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 oralways; when this block is itself skipped, the!dependency is left to its own normal freshness decision instead — seealwaysbelow 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 theboolvariable (or argument of this block)USE_AGENTistrue;fix?!USE_AGENTonly while it isfalse;fix?PROVIDER=claudeonly whilePROVIDERequalsclaude(any variable type, aselectonly accepts one of its choices), andfix?!PROVIDER=claudewhile 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 inenv=. 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 owndeps=conditions still apply). The variable is resolved like any other variable the block references (a prompt orconfigureentry,--set, its default), and only for chains that reach the edge. Afrom=-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'sinputs=(aninputs=declaration that only consumes that target'soutputs=is ignored entirely — not required to exist, not part of the fingerprint) nor as thefrom=source of a variable exposed throughenv=(the variable is left out of the environment and never required). When the entry is on, it is an ordinary explicitdeps=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 throughenv=:interpreter=, artifact paths, application arguments and conditions need the value always, andmeshfox validaterejects them. Conditions exist only indeps=:lease=,defer=andfrom=reject them.meshfox validatereports an empty condition (fix?), an unknown variable, a bare condition on a non-boolvariable and a comparison value the variable cannot hold.--no-depsdoes not followdeps=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 andfrom=sources even withoutenv=(useenv=separately to expose them to the process). A matchingoutputs=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 (useconfigure); 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 (includingtty), not services, forms or buttons.outputs=declares files; the singularoutput=below controls stdout presentation.--no-depsstill prepares computed variables and automatically found file producers. -
env— optional, comma-separated list of declaredmeshfox: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) orlocal=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"andenv="X,LOCAL=Y"mean exactly the same thing. A block with noenv=, path-variable references, interpreter-variable references, application arguments bound to variables (child[lang=$L]) ordeps=?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. Anenv=entry naming a variable nothing declares is ameshfox validateerror. An explicit alias may select a declared variable by argument, for exampleenv="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 andfrom=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 receivesPDF_URL_hyas localPDF_URL. Freshness includes only that selected variable's value; running itsfrom=source does not itself force the consumer to rerun. Static validation checks syntax and argument names, and all combinations of finiteselect/boolarguments (at most 4096); openstring/intselections are checked when the application is planned. Errors identify the application and selected name. This syntax is confined toenv=; 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 explicitdepsand implicitvia vardependencies 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 eachmeshfox:var from=to its exact producer node/block. Export signatures are visible before execution and remain visible when the block is folded;outputscontinues 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 implicitbash/shexecutor: the fence's own body is written to a fresh temp file and run asinterpreter target-tmpfile(word-split the same way a#!/usr/bin/env -S ...shebang line's own arguments would be — quoting is honored, sointerpreter="env \"my python\" -u"runsenvwithmy pythonas a single argument). Optional; when set,langno longer has to bebash/shfor the fence to count as runnable at all —langbecomes purely a syntax-highlighting hint, same role it already plays on afilenode's owninterpreter=(see "Node types" above, which this attribute is the fenced-block counterpart of — both parse the same way). Works on attyblock too — the interactive session runs directly underinterpreter(given a real pty/terminal) instead ofbash, e.g.interpreter="python3 -i"for an interactive Python REPL.A whole word starting with
$(e.g.$PYTHONininterpreter="$PYTHON -u") is substituted with the value of a declaredmeshfox:varof that name before word-splitting runsinterpreter target-tmpfile— the same resolution/prompting machineryenv=uses, just scoped tointerpreter=instead of the block's process environment. Unlikeenv=, the$here is mandatory, not cosmetic (interpreter=mixes literal words and variable references, so dropping it would makeinterpreter="PYTHON -u"ambiguous between "run the literal programPYTHON" and "run whateverPYTHONresolves to"); only a whole token is substituted, not$NAMEembedded inside a larger word (interpreter="/opt/$NAME/bin/python"stays literal). The natural use is afrom=-computed var — e.g. asetupblock that creates/updates a venv and reports its interpreter path viaMESHFOX_VARS_OUT(see "Computed variables" below) — sointerpreter="$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.tmpfile as CommonJS only, so an ESMimportneeds.js. A language with no entry, and thetext/tomlbodies of@namebuiltins, keep.tmp. -
tangle— optional flag (tangleortangle=true;tangle=falsedisables it); requires aninterpreter=(meshfox validateerror otherwise — without one the code runs underbash -cand 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 resolveimport/require,package.json,tsconfigand__file__from where the script lives, so a tangled block can import the modules sitting next to the canvas without asys.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 andttyruns alike; works with@namebuiltins 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, ornode-id/block-name, as indeps=) 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=andoutputs=. Because of that an extended block holds definitions only:deps,inputs,outputs,lease,defer,tty,serviceandcacheon it are ameshfox validateerror (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 (soextends=alone makes apythonfence 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.
cachecannot be combined withextends=(the cached output could not notice a change in the extended code). - Where it applies. A block with inline code: not a
filenode, aservice, aform, abuttonor ameshfoxfence. Only the executed text changes: the source editor,meshfox node blockand the file still hold the block's own code. Entries take a block name only (no!,?condition or[arguments]).tanglewrites 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 (ttyortty=true); this block wants a real interactive terminal instead of the usual captured/streamed output — e.g.bashon 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 withcache(meshfox validateerror) — an interactive session isn't the deterministic "exit code plus text"cachesaves/replays. Attyblock may be adeps=/from=target of any other block,ttyor 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 chainedttyblocks already hand it over twice, back to back (each still running its owndeps=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 ownautoclosefor returning to the canvas automatically once it exits.service— experimental, may change — optional flag (serviceorservice="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: adeps=chain that reaches aserviceblock continues immediately, without waiting for it. Mutually exclusive withtty/cache(meshfox validateerror) — a service is non-interactive by definition, and never exits under normal operation, so there's no deterministic "exit code plus text" forcacheto save/replay either. Aserviceblock may freely be adeps=/from=target of another block, or have its owndeps=— the chain-walk semantics already generalize, since "done" is just defined differently for it. See "Service blocks (experimental)" below.output="markdown"— optional; changes how acached run's captured stdout is written back (see "Cached output" below). By default it's wrapped in a passive```textfence, shown verbatim. Withoutput="markdown", it's spliced in as real Markdown instead — useful for a command that already prints Markdown worth rendering, e.g. apandasDataFrameviadf.to_markdown()(needs thetabulatepackage), which then renders as an actual table rather than preformatted text. Withoutput="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 withoutput="image"(meshfox validaterejects 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 validatealso rejects a value outside that grammar. Part of the cache fingerprint: editing it makes the cached output stale.autorun— optional flag (autorunorautorun="true"); in a live session (meshfox view/meshfox tui— never one-shotmeshfox 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 (viaenv=/interpreter=, transitively throughfrom=/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;autorunjust 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 aformfence's own Send (see "Form fences" below), but nothing requires one — any resolved-value change reaches it the same way, including one var's ownfrom=source producing a different result. Compatible withtty: 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; onlymeshfox validaterejects 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 acached block's own tabular output as a chart) rather than hard-coded to that one case.fold— optional flag (foldorfold="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 (cwith 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 (contrastunfold/"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 aform/autorunpair (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 onfold=onmeshfox: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 plainrunmay skip again, and other blocks' records and submittedformvalues are left alone. Meaningless with--no-deps, which is rejected together with it. The same flag is thefreshparameter of the MCPruntool.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=, itsdeps=closure and anyinputs=/outputs=fingerprint), so an install or build step that is requested directly only does its work when something changed. A block that isalways, 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 submittedformvalue, 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 MCPsession_resettool.
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]andimport[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 ownnamewhen the body is blank. - The body is never executed — running this block never treats it as
code.
meshfox validaterejectsinterpreter=,cache,env=, orttyalongsidelang="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 withname="full-import"is reachable asmeshfox run full-importfor free — the root's own blocks need no path segments — the same addressing every other runnable fence already has, nothing new.deps=can name anotherbuttonfence just as freely as any other block; the usual cycle detection covers it the same way.alwaysis deliberately not implied bylang="button"— see its own entry above ("the block actually requested... always runs for real"): the button itself never benefits fromalways, and forcing it onto the button'sdeps=chain would conflict withalways's existing "every consumer, every time" semantics. A pipeline step that genuinely needs to never be session-skipped still opts in with its ownalways, 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 declaredmeshfox:varshown as one labeled input, in document order;label=overrides the display text, falling back to the variable's ownprompt(itself defaulting toname) when omitted. Rendered using exactly the same per-type control the document-wide "configure variables" form already uses for that variable's owntype/choices(a checkbox forbool, a filterable list forselect(see "Choosing from aselect"), a masked input forsecret, ...) — a form fence doesn't invent a second type system, it's a different view onto the samemeshfox:vardeclarations. Everyfield var=must resolve to a variable actually visible in scope from the form's own node (the same node-subtree rule a node-scopedenv=reference already follows — see "Variables" below) and must not name afrom=-computed variable — submitting a value for one would let a human override what its source block computes, exactly whatfrom=exists to prevent;meshfox validaterejects either case. -
Dynamic
choices_var=anddefault_var=on the referenced variable work in aformfence too. Loading its fields resolves those references transitively and runs the neededfrom=source blocks and theirdeps=chains, with the same empty environment and graceful unresolved fallback as the pre-run form (see "Dynamicdefault/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 afieldline) — falls back to a plain"Send"when omitted. -
name="..."— required, same addressing convention as any other runnable fence (meshfox run-stylenode-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 itsdeps=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 (ito start editing it, or a click),meshfox form submitor the MCPform_submittool. -
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 withtextand nofieldis 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 aformfence (meshfox validaterejects it elsewhere). -
In the TUI a
formis drawn in the same frame as a code block (┌─ form · nameheader,│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(ori) starts editing — the header then reads· editingand the foot lists the keys —Escstops editing and keeps the selection, andrsays 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 formeshfox tui) — never the on-disk per-document cache, since every variable a form targets is already implicitlysession-scoped by being node-scoped in the first place (see "Variables" below). This is also exactly the momentautorun(above) reacts: submitting triggers everyautorun-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'senv=for the rest of that session, not just whicheverautorunblock happened to react to this particular Send. -
Reset. A form can put its fields back to their declared defaults (
default, or the resolveddefault_var) — the way to enter a series of similar records without clearing every field by hand. Two triggers:- the bare
resetflag on the fence's info string (```form name="tx" send="Add" reset) resets automatically after a Send, once everyautorunblock 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 barekeepflag (field var="CURRENCY" keep) is left alone by this automatic reset. - the form's Reset button (web UI),
[Reset]marker orCtrl-rwhile editing (TUI): always available, clears every field,keepor 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.resetis accepted only on aformfence (meshfox validaterejects it elsewhere);keepis only read on a form'sfieldline. The server endpoint isPOST /api/form/reset(respectKeep: truespares thekeepfields, as the automatic reset does); it announces the reset on/api/watchasform-reset, so every open web tab and TUI shows the defaults.meshfox form resetand the MCPform_resettool call it (asmeshfox form submitandform_submitcallPOST /api/form/submit). - the bare
-
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
ttysession, or the gap between a chain's steps counts; aservicedoesn't. Send commits the values and starts everyautorunblock 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/submitanswers409) before committing anything, and the web UI and TUI disable Send (and say why) while one is running. A form that triggers attyblock 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,serviceorautorun— none of those presuppose a real process of the form's own, which it never has (same reasoningbuttonalready gives for the ones it rejects). It can declaredeps=(what must run before the form is asked) and be named in another block'sdeps=.
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/watchannouncesform-awaited/form-resolved.GET /api/form/awaitinglists the forms runs are parked on;GET /api/form/fieldsreportsawaiting,cancelandtextfor one form. -
Send (
POST /api/form/submit, the web/TUI Send button,meshfox form submit, the MCPform_submittool) 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 emitsform-answered— the non-secret answers, so transcripts andmeshfox logsrecord what was decided — and the form'sstep-end.autorunblocks 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-xin the TUI form) fails the run: anerrorevent ("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,ccancels); with no terminal there is nobody to ask, so it cancels the form and fails with a clear error. The MCPruntool returnsstatus: "awaiting_form"with the form's address (not an error) and the run keeps waiting in the worker; the agent answers withform_submit. The web UI scrolls to the waiting form and shows itstext; 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 nosum): adds up an iterable's items,startfor an empty one.renderfences 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, orNonefor 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 owncreatedAt/updatedAt(see "Timestamps" above), as the literal RFC3339 string, orNoneif unset..created_at_ts,.updated_at_ts— the same two values as a Unix timestamp (int), orNone— 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 owncreated_at/updated_atstrings carry different literal offsets (plain string comparison doesn't). There's nonow()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 whenmeshfox checkhappens to run, breaking the same-input-same-output property every other constraint here has..children()— its direct structural children (same treeCanvas::childrenwalks — not extrameshfox:edgeparents)..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, orNone..nodes_with_tag(tag)— every node in the whole document whosetagsincludestag, regardless of where it's called from. Preferself.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()— afile-type node's own target, read fresh off disk and confined to the canvas's own directory (same boundary/cap thedisplay="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 returnNone— not an error — for anything that isn't afilenode, has no target, doesn't resolve, or (for the four parsers) doesn't parse that way; a constraint decides for itself whether that'sfail-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 runningmeshfox checkchose 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 callsfailpasses.
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:
| Function | Result |
|---|---|
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
ttystep,meshfox runchecks 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 canreadfrom the user, runvim, prompt for a password, whatever a normal interactive shell command could do. Earlier steps in the samedeps=chain still run captured/streamed exactly as usual; only thettystep(s) themselves take over the terminal, handing it back once each one exits. - Web UI —
meshfox viewgives 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 attyblock 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
ttyblock is never written back into the file — that's what thecacheconflict (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
ttyblocks — 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 hasautostop, 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 aserviceblock shows a small dot next to its title: white when idle, a smoothly pulsing green while running, red if crashed.sopens the services list; inside it,sstops the selected service andrrestarts 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
| Property | Default | Meaning |
|---|---|---|
start | required | Bash command; foreground by default, one-shot with oneshot: true or monitor. |
oneshot | false | Treat start as a bounded command that acquires an external resource; monitoring is optional. |
reuse | absent | Probe before initial start: 0 borrows an existing resource, 1 starts and owns it, any other code is an error. |
stop | absent | Explicit cleanup command for external resources; zero exit means success. |
monitor | absent | Optional long-running log command after acquisition, including when reusing a resource. Implies one-shot start for compatibility. |
check | absent | Liveness command: zero exit is healthy, any other exit is failure. |
check_interval | 5s | Interval between checks and timeout for each check; checks never overlap. |
ready | none | Repeated startup readiness command; exit 0 admits consumers. Separate from health check. |
start_timeout | 30s | Total deadline for reuse, start and readiness, including a hanging probe. |
restart_on_fail | false | Retry nonzero foreground/start exits or failed/timed-out checks. |
restart_delay | 2s | Initial retry delay; doubles on consecutive failures, capped at the greater of 60s and this value. Resets after a minute of healthy operation. |
stop_timeout | 30s | Deadline 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.
| Event | Started by this instance | Borrowed by this instance |
|---|---|---|
| Last temporary lease released | Run stop hook, terminate local processes | Terminate local monitoring/identity only |
| Readiness failure or worker automatic shutdown | Clean up the acquired resource | Leave external resource running |
| Autostop on idle worker | Stop acquired resource | Detach supervision |
| Explicit Stop/Restart of an active instance | Stop external resource | Stop 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
flockand isn't discoverable; any number of them may serve the same canvas side by side (everymeshfox run,viewor 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>.envis 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>/. Apython_venvblock's venv is<file>.venvthere, and anode_envblock'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 aretables-<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.tomlandsyntax/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 — with403and a message saying why (andwrite_raw, the single place that writes the file, refuses as well).GET /api/inforeports{"readOnly": true}.meshfox node ...,undo/redoand the MCPnode_*tools surface that message;run,check,validate,node show/findwork as usual. - Clients: the browser UI shows its
read-onlybadge with noEditbutton; the TUI'se(source editor) andH(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:
serviceblocks 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.ttyblocks work: the pty belongs to the one core that started it.run --detachis 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-onlycommand never asks aserver_socketcoordinator 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-execcommand that does use a coordinator says so in its request: the watcher protocol'sopenandget_portmessages 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 (afilenode pointing at one) sends the same field. The macOS daemon andmeshfox servehonour the field; the VS Code extension only starts workers that can run code, so it refuses such anopen(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-execprocess readsGET /api/info, and a worker whosenoExecis 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 ignoresno_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>"}
]
}
formatis the format version,1here. A reader that does not know a version refuses the file and says which version it found.entryis the path of the canvas to open.fileslists 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
0755if the source was executable by its owner and0644otherwise (0755for 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:
includetargets,filenode 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.mdwith 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--shallowthose canvases are not followed: they and what only they reference stay out, and afilenode 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 PACKunpacks 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 itsfilenode is read-only and no-exec too. The unpacked directory is the one place its canvases may read outside their own directories:viewnames it inMESHFOX_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 asnoexecalready. 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 DIRsets 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.FILEdefaults 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.--listprints what would go in (path, a tab, size in bytes, one file per line) and writes nothing; it fails in the same casespackdoes. 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/packandGET /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.DIRdefaults 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 notno-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] allowsays so;unpackthen prints a note saying so, with the line to add (it writes no configuration itself).meshfox view PACKneeds none.meshfox view PACKas above;--read-onlyand--no-execare 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 ownenv=(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"),dateordatetime(see "Date and datetime variables"), orlist(see "List variables"). It decides how to prompt (aboolprompts y/n, aselectshows itschoicesas a menu, adatea 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 anintthat is not an integer or adatethat 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 afrom=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--setor by editing the cache file. A secret's value is never echoed into the message. Only a declaration's owndefaultis taken as written.mode—type="file"only:open(default) orsave, 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 tonameitself.default— used if nothing else resolves the variable (see below). Mutually exclusive withdefault_var(meshfox validateerror).default_var—default_var="OTHER_NAME": this variable's owndefaultcomes from another declared variable's resolved value instead of a literal string — e.g. a default install path computed by actually runningpwdin some relevant directory (declare that as its ownfrom=-computed variable, then reference it here by name). Not a$-prefixed overload ofdefault=itself —default='s value is genuinely freeform text, where a$nameconvention would be ambiguous with a literal default that happens to look like one (unlikeenv='s dollar-stripping, which is safe only because anenv=entry is always a name reference, never literal text). Mutually exclusive with a literaldefaultand withfrom=(meshfox validateerror either way) — a computed variable is never prompted for, so it has nodefaultto supply in the first place.choices— comma-separated; required whentype="select"andchoices_varisn't given. Mutually exclusive withchoices_var(meshfox validateerror).choices_var—choices_var="OTHER_NAME", same idea asdefault_varbut forchoices(the referenced variable's resolved value is split the same comma-separated way a literalchoices=already is) — e.g. aselect's options populated by actually runningaws list-regions. Requirestype="select", same as a literalchoices=does. Mutually exclusive with a literalchoicesand withfrom=, same reasoning asdefault_var.secret— flag (secretorsecret=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 (requiredorrequired=true). Arequiredvariable's owndefaultis never taken silently, even when nothing else resolves it — it still needs one explicit interactive answer, the same as if it had nodefaultat all, withdefaultoffered 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. Withoutrequired, a variable with adefaultis simply used, never prompted for, unless it has nodefaultat all (in which case it always needs an answer either way).session— flag (sessionorsession=true). Never read from or written to the on-disk cache — unlikesecret, input isn't masked; this is about lifetime (never remembered past the currentmeshfox runinvocation), not confidentiality. Always true for a node-scoped declaration (one outside the root node) whether or not it's written explicitly — see above. Combined withrequired, 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 plainsessionwithoutrequiredstill silently resolves from--set/the environment/adefaultwhen 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 singlemeshfox runinvocation is still only ever prompted for once within that invocation —sessiononly skips the cache, not the same once-per-invocation reuse every other variable already gets (see "Consumption" below). Mutually exclusive withfrom=(a computed value is already never cached, sosessionon it would be a no-op).from—from="node-id/block-name"(or a barefrom="block-name", meaning a block in the same node this variable is itself declared in — the same shorthanddeps='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 ownMESHFOX_VARS_OUTfile — see "Computed variables (from=)" below. Mutually exclusive withdefault/default_var,required,secret,session, andchoices_var(ameshfox validateerror to combine any of them withfrom) — none of those mean anything for a value that's never cached, defaulted, or prompted for.type/choicesstill 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:
[interpreters.python_venv] python = "python3.12"inconfig.toml(a command name or a path; exported to the script asMESHFOX_CONFIG_INTERPRETERS_PYTHON_VENV_PYTHON). A command that is not found is an error, not a fallback.- For a
tomlfence, the pyproject's ownproject.requires-python:python3if it satisfies the specifier, otherwise the newestpython3.NonPATHthat does. The specifier is evaluated for>=,<=,>,<,==,!=,~=and==X.Y.*, comma-separated; anything else is reported and ignored (the systempython3is used). If no interpreter onPATHsatisfies it the block fails and lists the versions it found. - Otherwise the
python3first onPATH.
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.
| Fence | Invocation | Result |
|---|---|---|
tty | claude "<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 other | claude -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
- 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:
- A reference to any other name (
$PATH,$HOME, a typo, an undeclared variable) is left exactly as written. - The bare form matches a whole name only:
$TOPICmatches,$TOPICSis a different name and$TOPIC_Xis not$TOPICfollowed by text.${TOPIC}is unambiguous. $$NAMEis always a literal$NAME, whether or notNAMEis 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
secretfield 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 onCtrl-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 adefault=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 declaresecrets = ["DB_PASSWORD"]besidevars. The names are in the file; the values are in the keychain, filed under the section's scope (global,global:<path= as written>orproject:<canvas_root>). They take the same place in the precedence chain as avarsentry 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>, seetangleunder "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>.venvand.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).DIRdefaults to the current directory;--recursivealso walks subdirectories, never through a symlink and skipping.git,node_modules,targetand.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 aremeshfox 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>.envvariable cache and.meshfox/<canvas file>.session.sqlite3run history (with its-wal/-shmcompanions) are renamed with it, and a leftover.worker.lockis dropped; a directory is moved as one,.meshfox/included. Withsecret_store = "keychain"the saved answers are filed under the canvas's absolute path (doc:<path>, and an[[env]]section's underproject:<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.envfile and just move with it.) The.venvand.nodeenvironments hold absolute paths, so they are deleted, not moved — the next run of the setup block builds them again. Nothing is overwritten:DSTmust not exist (unlikemv(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 underSRC'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-runprints all of this and moves nothing. Across filesystems it copies and then deletes. Likeprune, it runs in the CLI's own process.meshfox env list [CANVAS|DIR] [--recursive]andmeshfox 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.listshows each with its kind,name=and size, and marks one whose canvas file is gone.rmdeletes them for a canvas that still exists — the casepruneleaves alone — so the next run of the setup block builds them again, e.g. after a system Python upgrade (run it withmeshfox run --fresh: the block is usuallycached and deleting its directory does not make it stale). Without--nameit removes the usual environment and every named one;--nameremoves just that one (the name must match thename=option's shape, so it cannot leave.named/);--python/--nodelimit 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. Likeprune, it runs in the CLI's own process. The MCP server hasenv_listandenv_rmfor the same.meshfox secret set|show|rm NAME [--canvas F | --global [--path P] | --project DIR]manages values;showprints 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 assecret, or the config no longer lists it insecrets);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 pruneshows the orphans and, with--yes, deletes them from the keychain and the index. The MCP server'ssecret_listreturns 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 theMESHFOX_VARS_OUTenvironment variable — the block is expected to appendNAME=valuelines to it (sameKEY=value-per-line format the var cache uses) before exiting. A temp file, not a pipe: nomkfifo/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 afrom=target for anything never seesMESHFOX_VARS_OUTat all. - Only trusted on a
0exit. 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. A0exit that didn't produce a value for some variable declaredfrom=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 explicitdeps=entry — running (or resolving the chain for) any block whoseenv=references afrom=-declared variable automatically runs that variable's source block first. Unlikedeps=, this edge is never skipped by--no-deps/the web UI's plain "run" (as opposed to "run chain") button: adeps=dependency might already have fresh cached output, a legitimate reason to skip rerunning it, but afrom=-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 afrom-declared variable, even if they name it — only an actual run of itsfrom=block can, so a stale or hand-typed value can never impersonate a computed one. Consequentlymeshfox configureand the web UI's pre-run vars form never offer afrom-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.
REGIONhere doesn't declarefrom=itself, but resolving it requiresREGIONS_LISTto already be resolved — so a block referencingREGIONviaenv=gets an implicit dependency onaws/list-regions(viaREGIONS_LIST's ownfrom=) the same way it would if it referencedREGIONS_LISTdirectly. This chains arbitrarily deep: adefault_var/choices_varreference 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_LISTresolves, its value becomesREGION's own effectivechoices(split the same comma-separated way a literalchoices=is) for exactly this resolution —REGIONstill goes through its own full override/env/ cache/default/prompt chain afterward, using that substituted value where a literalchoices=/default=would otherwise sit. If the reference isn't resolvable yet (rare in practice, since the expecteddefault_var/choices_vartarget isfrom=-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 withfrom=— see each attribute's own entry above.meshfox validatecatches adefault_var/choices_varnaming 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 inweb/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-Uclears the text,Escclears the text first and closes the list on the second press. The terminal prompt ofmeshfox run/meshfox configurelists 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. Aselectwithchoices_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) carrieschoicesPending: {"var": "<the choices_var variable>", "reason": "..."}instead of an emptychoices.reasonsays why the variable was not computed when something tried to: the source block (or one of itsdeps=) 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: aninterpreter="$PYTHON"that a setup block builds is expanded and works. A script that needsenv=input of its own fails there — and thereasonis 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
 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.mp4the worker createsdir/— neverx.mp4itself.
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. AdateisYYYY-MM-DDand must be a real day (2026-02-30is rejected,2024-02-29is fine). Adatetimeis RFC3339 with an offset, the same shape as thecreatedAt/updatedAtstamps (see "Timestamps"):2026-10-25T14:30:00+03:00or...Z, aTbetween 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 adefaulton ameshfox:arg). There is nomin/max,format=, or relative default such astoday/now. - Interactive text fields are lenient and normalize what was typed into
the canonical value: for a
datetime, a space may replace theT, seconds are optional, and the offset may beZ,+03:00,+0300or+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 andformfences;--setand 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 exceptselect; defaultstring). Every element is validated against it, wherever the list comes from. Withchoices="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 besecret, and it is not a valid type for ameshfox:arg(an argument is one value). -
Writing a list. A
from=source block (or any producer of the variable's value) writes to itsMESHFOX_VARS_OUTfile 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 plainPAGES=replaces earlier elements, andPAGES[]=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."}format result (none), jsona compact JSON array ["1","Acme, Inc."]— lossless, the defaultlinesone element per line spaceshell-quoted words separated by spaces, safe for evalandset --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 validateerror. The format is part of the block's fingerprint. -
Fan-out.
name=*$LISTin adeps=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 list3 1 2runscut[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 withtype="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 adate. Adatetimeis<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-Oon a focuseddate/datetimefield of the variable form or aformfence opens a calendar modal over it, starting from the field's value (or now). The field ends in a clickable[Pick… ^o]marker, asdir/filefields end in[Browse… ^o]. Keys:←/→move a day,↑/↓a week,PageUp/PageDown(or[/]) a month,Home/Endthe first/last day of the month,ttoday,Enterchoose,Esccancel; weeks start on Monday. For adatetimethe 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/PageDownby ten,nsets 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 roleccmake/cmake -Lplays 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 fromGET /api/vars/configure, the same one the web UI and the TUI use, so aselectwithchoices_var=is asked with its real, computed choices, and the answers are written back withPOST /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 runonly 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 whoseenv=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 finalloadstep 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. Afrom=-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, whichrunotherwise requires whenever some referenced variable is still missing after--set/env/cache/default. A--setvalue is saved to the cache regardless of whether anything in the current invocation actually references it, same ascmake -Dalways updatingCMakeCache.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; declaringunfoldflips that default to everything expanded. A single node can still override whichever default applies to it with its ownfold=attribute (see "File structure" above) — the option only sets what an unset node falls back to.auto-timestamps— opts the whole document in toinsert_child_node/set_node_body's automaticcreatedAt/updatedAtstamping (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 (acached 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 explicitnode 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:
- The node's own explicit
color=, if it has one — always wins. - Otherwise, the color declared for the first of the node's own
tags(in the order written on that node) that has one. - 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 (<!--) 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" -->
{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'sdepschain runs first, automatically, in dependency order; a dependency shared by several requested blocks only runs once.--no-depsskips 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 adefaultblock (see "Runnable code fences" above — one explicitly flaggeddefault, or one whose name already matches the node's ownid), the trailing name can be dropped:meshfox run tests smoke-testaddresses that node's default block directly, instead ofmeshfox 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 justbash) 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-pastemeshfox 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. Attyblock 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/cyclicdeps=, no invaliddeps=?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.