canvas-toml
v1.0.0localskills install rLl2iTJe4x0 downloads
Created Jun 15, 2026
arav garg
Skill Content
# Fused canvas folder + `canvas.toml`
A Fused canvas stored in git is a folder containing one `canvas.toml` plus per-UDF source files. Push to the connected repo and Fused picks up the changes automatically.
## Folder layout
```
my_canvas/
canvas.toml # required: layout and node metadata
udf_0.py # Python UDF source
udf_1.py
widget.json # optional: json_ui widget (stem = udfName)
textbox.md # optional: textbox/markdown widget (stem = udfName)
page.html # optional: html_template widget (stem = udfName)
_shared.fused # optional: access control
```
- `<name>.py` / `.json` / `.md` / `.html` — source for a UDF; the **stem must match a node's `udfName`**.
-- File name stems must be unique in a canvas. The following is OK: `abc.json`, `def.py`. The following will NOT work: `abc.json`, `abc.py`. **The server error for this is misleading** — it says "A UDF with one of those slugs already exists in this collection", which sounds like a cross-canvas naming collision. It's actually caused by the same-stem conflict in your local folder. Fix: delete one of the conflicting files and push again.
-- JSON-UI files you should consult the `json-ui-schemas` skill for how to write, validate, and debug them. They contain JSON5.
- `_shared.fused` — omit ⇒ private (author only). Empty file ⇒ team. Set `access_scope = "public"` for public. Set `token = "<value>"` to control the URL slug.
Do **not** mix `canvas.toml` with the legacy `collection.json` layout in one folder — `canvas.toml` wins if both are present.
## `canvas.toml` example
```toml
type = "canvas"
version = 2
# previewImageUrl = "https://example.com/preview.png" # optional
[canvas]
edges = [
["controls", "udf_0"], # controls widget drives udf_0 via canvas params
["udf_0", "udf_1"], # udf_1 depends on udf_0's output
]
[[canvas.nodes]]
udfName = "controls"
description = "Input controls"
title = "Controls"
visible = true
x = -2515.89
y = -288.929
zIndex = 1
width = 480
height = 700
[[canvas.nodes]]
udfName = "udf_0"
description = "My first UDF"
title = "udf_0"
visible = true
x = -1985.89
y = -288.929
zIndex = 2
width = 700
height = 500
[[canvas.nodes]]
udfName = "udf_1"
description = "My second UDF"
title = "udf_1"
visible = true
x = -1235.89
y = -288.929
zIndex = 3
width = 700
height = 500
[canvas.viewport]
x = 1437.76
y = -80.62
zoom = 0.5
[canvas.viewportBounds]
minX = -2875.52
minY = 161.24
maxX = -1843.52
maxY = 1703.24
```
## Top-level fields
| Field | Type | Notes |
|---|---|---|
| `type` | string | Always `"canvas"` |
| `version` | integer | Always `2` |
| `name` | string | Optional display name (server falls back to folder name) |
| `previewImageUrl` | string | Optional preview image URL |
## `[canvas]`
| Field | Type | Notes |
|---|---|---|
| `edges` | array of `[source, target]` pairs | Each pair references `udfName` values |
| `nodes` | array of node tables | See node fields below |
| `viewport` | table | Optional — `x`, `y`, `zoom` |
| `viewportBounds` | table | Optional — `minX`, `minY`, `maxX`, `maxY` |
## `[[canvas.nodes]]`
| Field | Type | Required | Notes |
|---|---|---|---|
| `udfName` | string | yes | Must match a source file stem |
| `x`, `y` | float | yes | Canvas coordinates |
| `zIndex` | integer | yes | Layer order |
| `width`, `height` | integer | yes | Pixel dimensions |
| `description` | string | no | Defaults to `"UDF: <name> (auto)"` |
| `title` | string | no | Defaults to `udfName` |
| `visible` | boolean | no | Output panel visibility; default `true`. **Must be `true` for any UDF referenced via `{{udf_name}}` in a JSON-UI `sql-runner`.** Hidden nodes do not auto-execute on canvas load, so they have no cached result for widgets to read. |
| `type` | string | no | Omit for standard UDF; `"udf-folder"` for folder nodes |
| `textBoxColor` | string | no | Background color for textbox nodes |
| `textBoxGradient` | boolean | no | Textbox gradient toggle |
| `textBoxScaleFactor` | number | no | Textbox content scale |
| `textBoxAlignment` | string | no | Textbox content alignment |
## Folder nodes (`type = "udf-folder"`)
Visual grouping only — child UDFs still live as flat files in the canvas folder.
```toml
[[canvas.nodes]]
type = "udf-folder"
folderName = "Section_1"
folderColor = "#9370DB40"
childUdfOrder = ["udf_0", "udf_1"]
# isLocked = true # optional, default false
x = -2600.0
y = -350.0
zIndex = 0
width = 1000
height = 700
```
| Field | Notes |
|---|---|
| `folderName` | Display name |
| `folderColor` | Optional hex+alpha color |
| `childUdfOrder` | Ordered `udfName` list for UDFs in this folder |
| `isLocked` | Locks child editing; default `false` |
## Canvas architecture patterns
### Loader / analysis split
Separate expensive data fetching from the analysis that uses it. Put the expensive call in a hidden `fetch_*` or `load_*` UDF so it caches independently of the analysis parameters. The visible analysis UDFs call it via `fused.load()`:
```
[fetch_data] (hidden, visible=false, cached)
↓ fused.load("fetch_data")()
[analyze_data] (visible — lightweight, re-runs fast when params change)
↓
[display_widget] (visible — JSON widget or map)
```
```python
# fetch_data.py — hidden, cached, no parameters that change often
@fused.udf(cache_max_age="1h")
def udf(source_url: str = "s3://..."):
import pandas as pd
return pd.read_parquet(source_url) # slow — cached for 1 hour
# analyze_data.py — visible, fast, re-runs on every slider/dropdown change
@fused.udf
def udf(threshold: float = 0.5, region: str = "north"):
df = fused.load("fetch_data")() # returns instantly from cache
return df[df['value'] > threshold]
```
This pattern means the user can scrub an interactive control without re-fetching the data source each time.
### Single-item + parallel batch dual mode
Design the per-item UDF to work as a standalone visible node, then add a separate orchestrator that fans it out. This gives you two working paths in the same canvas:
```
[single_item_analysis] ← single-item, visible, good for debugging
[batch_analysis] ← orchestrator, calls single_item_analysis.map(items)
```
Keep the single-item UDF as the source of truth. The orchestrator is just:
```python
@fused.udf
def udf(items_csv: str = "a,b,c"):
worker = fused.load("single_item_analysis")
items = [x.strip() for x in items_csv.split(",")]
return worker.map(items).df()
```
The worker UDF must include the item identifier as a column in its output — after `.df()` concatenates all results, you need to know which rows came from which item.
## Authoring rules
- Ephemeral UI state (selection, sidebar) is **not** stored — only nodes, edges, viewport.
- When adding a UDF: create the `.py` (and any widget file) **and** add a matching `[[canvas.nodes]]` entry with the same stem.
- When removing a UDF: delete its source file(s) **and** its node entry, plus any `edges` referencing it.
- UDFs can call each other via `fused.load("<udfName>")`. For multiprocessing, split into a new UDF.
### Testing UDFs that call sibling canvas UDFs
When a UDF uses `fused.load("other_udf")` to call a sibling UDF within the same canvas, **local testing with `fused.load("my_udf.py")` will fail** with a "UDF not found" error. The local file context has no canvas, so the runtime cannot resolve sibling UDF names.
The correct testing approach:
1. Push the canvas first: `fused canvas push ./my_canvas`
2. Then test the pushed UDF by name: `fused.load("my_udf")()`
Running by name resolves the UDF from the server with full canvas context, so `fused.load("other_udf")` inside it can find its sibling.
## Canvas naming
Canvas names (used in `fused canvas push --canvas` and the URL slug) must contain **only letters, numbers, and underscores** — spaces and hyphens are rejected with a 422 error. Use the optional `name` field in `canvas.toml` for a human-readable display name; it is separate from the URL slug.
```toml
name = "My Demo Canvas" # display name — spaces OK here
```
```sh
# slug — underscores only
fused canvas push ./my_canvas --canvas "my_demo_canvas"
```
## Edges — rules and common mistakes
`edges` serve two purposes: (1) **visual data-flow arrows** users see in the canvas, and (2) **canvas parameter propagation** (values set by a widget/node that downstream UDFs must receive).
**`edges = []` is almost never correct for a multi-node canvas.** An empty edge list means no connections are visible and no parameter propagation *from other nodes* — any UDF that should react to a widget or upstream UDF's output will run in isolation. The only legitimate case for `edges = []` is a canvas where every node is fully independent (no shared params from another node, no data dependencies).
**Critical: `fused.load()` does NOT create canvas edges.** When a UDF calls `fused.load("other_udf")` internally, that is a Python-level import — it has nothing to do with canvas edges. You still must add an explicit edge in `canvas.toml` for both data-flow visualization and parameter propagation. Do not write `edges = []` just because UDFs chain via `fused.load()`.
**When to add an edge from A → B:**
- Node B reads a canvas parameter (`lat`, `zoom`, any shared param) that is set by node A (e.g. a widget node)
- Node B calls `fused.load("a")` internally — add `["a", "b"]` to show the dependency visually
- Node B's output logically depends on node A's output
**Edge syntax** — each entry is `["sourceUdfName", "targetUdfName"]`. Using the template example above:
```toml
[canvas]
edges = [
["controls", "udf_0"], # controls widget drives udf_0 via canvas params
["udf_0", "udf_1"], # udf_1 calls fused.load("udf_0") internally
]
```
**Practical checklist before finalizing `canvas.toml`:**
1. For every widget/controls node: does it have an edge to each UDF it drives? If not, canvas params won't propagate.
2. For every `fused.load("x")` call in UDF `b`: is `["x", "b"]` in `edges`? If not, the dependency is invisible to users.
3. Is `edges` still `[]` with more than one node? If yes, verify every node is truly independent — this is almost certainly wrong.
For JSON-UI widget nodes that reference a UDF via `{{udf_name}}` SQL (typically via `sql-runner`), also add an edge from that UDF node to the widget node: `edges = [["my_udf", "my_widget"]]`. Without the edge, the UDF data is not reachable at runtime.
## Node sizing and viewport
Node `width`/`height` are canvas pixels. Typical starting values:
| Node type | Width | Height |
|---|---|---|
| JSON UI inputs panel | 400–500 | 600–900 |
| HTML / preview panel | content width + padding | content height + padding |
| Python UDF | 600–800 | 400–600 |
Place nodes left-to-right by incrementing `x` by `width + gap` (30–50px gap). Keep `y = 0` for a flat layout.
For the viewport, set `x`/`y` to roughly the canvas midpoint and choose `zoom` between `0.5` (overview) and `1.0` (full size); `0.75` works well for two medium nodes side by side.