Widgets — getting a rendered result back
Part of the Fused skill set — don't work from it alone. Fused is workspace ⊃ project ⊃ UDF. Before acting, also load fused-projects for the end-to-end
model (creating, running, opening, and deploying a project's widget). For a
question/approval/plan UI load fused-feedback. See fused-guide for the
full set — this skill assumes that context.
In most Fused flows the thing a human wants back is a widget (an
interactive dashboard), not a raw value. A widget is a JSON config; running it
yields resolved rows that a renderer turns into a visual. This skill covers
authoring widgets and getting them in front of a human.
There are NO MCP widget tools. Agents author widget files; humans view
them through the CLI (fused widget open / the parley). The separate flow UI
(fusedio/flow — started with the flow CLI, or npx @fusedio/flow once
published) can also render them, but driving flow is out of scope for this
skill; everything below is the fused CLI, never MCP. (See
fused-projects for where this sits in the project lifecycle, and
fused-cli for the full widget command flag tables.)
What a widget is
A widget is a single JSON document — a tree of nodes, each
{ "type": "<component>", "props": { … }, "children": [ … ] }. It lives in one
of two places in a project — and the two render on different surfaces, so the
choice matters:
widgets/<stem>.json — a saved project dashboard, addressed by its
stem (fused widget open <stem> --project <p>). This is also the form the
separate flow UI live-renders on its project surface (out of scope here).
Use this form when the human will browse the dashboard.
scripts/<name>/main.json — a json-kind UDF: a first-class, deployable
entrypoint (deploys to a stable widget URL). Preview it rendered with
fused widget open scripts/<name>/main.json (the file render path) or by
deploying it. In the flow UI it appears as source ("View source ↗"), not
a live render.
The config is identical either way (same {{ref}}/$param grammar), so this is
purely where the file lives. The default for "show the human a dashboard" is
widgets/<stem>.json. If you also need a shareable URL, keep the deployable copy
at scripts/<name>/main.json as well.
Always pair a widget with a spec. For every widgets/<stem>.json you save,
write a widgets/<stem>.spec.md contract sidecar next to it — purpose, the
{{udf}} data it binds, and the components + their SQL — the SAME spec↔file
pairing a UDF has (scripts/<stem>/spec.md). The app renders it in the widget's
Preview ⇄ Spec toggle, and data-qa verifies the widget against it; a widget
without its spec.md is incomplete.
The renderable component set is fixed and comes from a single generated source
of truth (widgets/components.json); a type outside it is a hard error. The
current components:
| Group |
Components |
| Layout / display |
div, form, text, markdown, diff, html, image, video, iframe, canvas, button, metric |
| Charts |
bar-chart, line-chart, scatter-chart, donut-chart, heatmap-chart, stacked-bar-chart, stacked-area-chart |
| Data |
sql-table |
Inputs (write a $param) |
dropdown, checkbox-group, slider, number-input, text-input, text-area, datetime-input, color-input, file-upload, camera-input, gallery-input |
| Feedback primitives (Fused-owned; carry intent beyond a scalar value — never SQL-referenced) |
button, video-review, canvas, task-board |
| Source |
sql-runner |
Input components carry a param prop and a defaultValue; they seed the param
store on first paint and re-resolve dependent queries when the human changes
them.
Use this catalog — not the external Fused-branded skills. Author widgets
ONLY against this catalog (the agent_core widget set). Do not use any
external Fused-branded skill (fused:*, e.g. fused:json-ui-schemas,
fused:canvas-toml) or the hosted Fused product's JSON-UI schema — those target
a different product and carry components this catalog does not support, and an
unsupported type is a hard render error. The live,
authoritative list for the project you're in is the widget_components array in
get_project_context ([{type, hasChildren, isInput}, …]) — orient on it.
Component reference (key props — style applies to all, omit unless overriding)
Charts read fixed result columns (alias your SELECT to them), not props:
| type |
reads columns |
key props |
bar-chart |
label, value |
sql, title, barColor, horizontal, showValues, xAxisLabel, yAxisLabel |
line-chart |
label, value, opt series |
sql, title, lineColor, curveType, showArea, xAxisLabel, yAxisLabel |
stacked-area-chart |
label, value, opt series |
sql, title, curveType, showLegend, showBrush, xAxisLabel, yAxisLabel |
stacked-bar-chart |
label, value, opt series |
sql, title, horizontal, showLegend, barColor, xAxisLabel, yAxisLabel |
donut-chart |
name, value |
sql, title, innerRadius, showLabels, showCenterTotal |
scatter-chart |
x, y, opt series/size/label |
sql, title, pointColor, xLabel, yLabel |
heatmap-chart |
x, y, value |
sql, title, lowColor, highColor, showValues |
Always title chart axes. Set xAxisLabel/yAxisLabel on the cartesian charts above
(and xLabel/yLabel on scatter-chart) so each axis names what it shows — an unlabeled
axis is an incomplete chart. These are axis titles, separate from the per-tick labels.
Display / data:
| type |
key props |
text |
value / sql, variant (default/muted/small/large/h1–h4) — a single value |
markdown |
value / sql — GitHub-flavored prose (headings/lists/tables/code) |
diff |
before + after (computed) OR diff (unified string), oldLabel, newLabel |
metric |
value / sql, label, format, prefix, suffix, decimals |
sql-table |
sql, title, sortable, filterable, maxRows; tree-view pair idColumn + parentColumn (rows nest by parent id). No hiddenColumns/columnVisibility — select only the columns you want in the sql. |
image / video |
src (+ alt/poster/controls…) |
html |
value (raw HTML — escape hatch; prefer markdown) |
iframe |
src (absolute http(s)), title, allow |
div / form |
style (layout container; children) |
map |
layers (UDF geometry), mapStyle, centerLng, centerLat, zoom, param, sendParam — simple geometry |
fused-map |
layers (deck.gl: scatterplot/geojson/h3/heatmap/arc/mvt/raster), basemap, centerLng, centerLat, zoom, param, showLegend, showLayerPanel, showBasemapSwitcher — advanced |
map-bounds |
param, centerLng, centerLat, zoom, mapStyle, autoSend, buttonLabel — viewport-as-input only (no data) |
sql-runner |
name, sql — server-side source container (runs a named query once, exposes it to descendants as {{name}}); not a rendered output. Renders everywhere (no heavy deps). |
Maps render in the native renderer only. map, map-bounds, and fused-map
need heavy WebGL deps + external tiles the self-contained deployed bundle does
not ship, so the deployed build aliases the map modules to a placeholder —
they render only in the native renderer (fused widget open / the parley / the
flow UI), not on a deployed URL.
Inputs (write param; all take param, label, defaultValue):
| type |
extra key props |
dropdown |
options [{value,label}] (REQUIRED, non-empty), opt sql |
checkbox-group |
options [{value,label}] — multi-select (writes an array) |
slider |
min, max, step |
number-input / text-input / text-area |
placeholder (+ rows for text-area) |
datetime-input / color-input |
— |
button |
label, action, submit, variant |
Prose vs raw HTML vs scalar: use text for a single value (a label, a count),
markdown for prose / reports / notes (headings, lists, tables, code — GitHub
flavored), and html only when you need raw HTML. Use diff to show the
change between two texts — pass before + after (the line diff is computed for
you) or a precomputed unified-diff string in diff; built for reviewing markdown
spec changes. Lean on the built-in defaults — omit style unless you need a
deliberate override (one consistent default look across every surface).
Don't inline a large dataset into an html value — keep large data outside
the widget, where the project stores its data, and load it at runtime. Concretely:
store the data as a file in assets/, add a backing UDF in scripts/ that reads
it with openfused.asset_path(...), and bind the widget to it via {{udf_name}}
in a sql prop. A widget config over 512 KB is rejected at save and resolve
time (ConfigTooLargeError) — an inlined dataset also rides in every resolve
request, which the app server caps at 1 MB.
checkbox-group is the multi-select reply channel — the array twin of
dropdown. It is the input to reach for when an ask allows more than one
answer (an ask_user widget whose question allows more than one choice). It
writes the chosen option
values to its param as a string[]. Because the param holds an array,
it (like sql-table's selectionParam and video-review) must never be
referenced in SQL — $param is text substitution and only scalars are
SQL-safe. Use a dropdown for single-select.
The core pattern: a py UDF computes, a json widget visualizes
This is the shape of almost every project. A py UDF returns a DataFrame; a
json widget binds to it in a DuckDB SQL string via the {{ref}} grammar.
scripts/sales/main.py — the data UDF:
import fused
@fused.udf
def main(region: str = "all"):
import pandas as pd
df = pd.read_parquet("s3://my-bucket/sales.parquet")
if region != "all":
df = df[df["region"] == region]
return df[["month", "region", "revenue"]]
widgets/sales-board.json — the saved dashboard that renders it (use
scripts/sales-board/main.json instead if this widget is a deployable entrypoint):
{
"type": "div",
"props": { "style": "display: grid; gap: 16px; padding: 16px" },
"children": [
{ "type": "text", "props": { "value": "Revenue across regions", "variant": "h3" } },
{ "type": "dropdown", "props": {
"param": "region", "label": "Region",
"options": [{"value": "all", "label": "All"}, {"value": "emea", "label": "EMEA"}],
"defaultValue": "all" } },
{ "type": "slider", "props": {
"param": "min_revenue", "label": "Min revenue",
"min": 0, "max": 500, "defaultValue": 0 } },
{ "type": "metric", "props": {
"label": "Total revenue", "format": "currency", "prefix": "$",
"sql": "select sum(revenue) as value from {{sales?region=$region}}" } },
{ "type": "bar-chart", "props": {
"sql": "select month, sum(revenue) as revenue from {{sales?region=$region}} where revenue >= $min_revenue group by month order by month" } },
{ "type": "sql-table", "props": {
"title": "Raw rows",
"sql": "select month, region, revenue from {{sales?region=$region}} where revenue >= $min_revenue order by month limit 50" } }
]
}
The dropdown/slider write $region/$min_revenue; the metric,
bar-chart, and sql-table read them and re-resolve when they change.
Reading a bundled project asset (a file under the project's assets/): use
openfused.asset_path(...), never a relative ./assets/..., a __file__-relative
path, or a hard-coded absolute path. A UDF runs in a throwaway sandbox cwd, so only
a project-root-anchored path resolves — and the widget render path is exactly where
the relative forms silently break (blank dashboard / FileNotFoundError):
import openfused
df = pd.read_parquet(openfused.asset_path("data.parquet")) # robust, one-shot
Geo data → always a map. When the data has a geographic dimension (lat/lon,
geometry, or geocodable place/address columns), include a fused-map in the
dashboard by default — unless the user explicitly says not to. A map is the default
for geo data, not an optional extra.
The {{ref}} / $param grammar (essentials)
{{sales}} — the whole result of the sales UDF, default params.
{{sales?region=$region}} — pass kwarg region bound to the param $region.
{{sales?region=emea&limit=50}} — bare literals (string emea, number 50);
single-quote a value that must stay a string or contain & = whitespace:
{{sales?region='emea, west'}}.
name may contain - ({{my-udf}}). Two refs with identical name+args share
one UDF run / one DuckDB view; distinct args run the UDF again.
- Cross-project (
{{_core.proj.udf}}) — a bare {{udf}} resolves in the
current project; a three-segment ref reuses a UDF from the built-in _core
workspace, e.g. {{_core.task-management.create}} (read path) or a button
executor firing the same name (write path). Only the allowlisted _core
workspace is reachable (any other first segment → unknown endpoint); its
source is the packaged core_source_dir() but it RUNS under the
consuming project's venv, so keep shared UDFs dependency-light. _core refs
resolve on every local surface — they all route through fused dev serve,
whose directory-addressed modes (?dir=/?projectDir=, used by widget open /
parley) inject the built-in _core shared root by default — so a standalone
widget (e.g. the task-board's mutateBackend: "core") can drive the built-in
_core UDFs; only the deployed-serve bundle can't.
App-parity note: this is an extension specific to this platform (agent_core) —
a cross-project ref won't resolve if pasted into the external Fused app.
$name is an inline text substitution (not a DuckDB bind param), so it
works anywhere — including inside '…' and "…". Grammar: $[A-Za-z_]\w*.
Substitution is context-aware (quotes are doubled; comments/dollar-quoted
bodies are left verbatim). This matches the Fused app byte-for-byte, so a
config authored here pastes into the app and behaves identically.
The {{ref}} / $param grammar above is the full contract.
Archived widgets (soft-deleted, reachable by link). When a human archives a
widget in the app it moves to archive/widgets/<stem>.json and is read-only —
gone from the canvas, the Widget tab, and get_project_context, but still loadable
through a task deep-link. A bare {{udf}} resolves from the live scripts/
dir; if that UDF was also archived, the resolver falls back to
archive/scripts/<udf>/ so a fully-archived widget + UDF pair still resolves its
data when opened via the link. The fallback fires only for the exact ref the
widget names — archived UDFs are never enumerated, so they stay out of every catalog.
You don't author or restore archived widgets from here (that's an app + human
action); just know an archived widget you reach by link still renders.
Core UDFs → custom views (no project setup)
The built-in _core workspace ships ready-to-use UDFs that any ad-hoc widget can
read or act on directly via a {{_core.<project>.<udf>}} ref (read) or a button
executor firing the same name (write). This is how a user gets a custom view
of Fused's own state — a bespoke task board, an inbox triage panel, a run
monitor — without authoring any backing UDF: bind a chart/table straight to a
core UDF. No setup, no env, no uv add for the core source itself — the
directory-addressed surfaces (widget open, parley, fused dev serve) inject
the _core root automatically.
The shipped _core projects and their UDFs:
| Project |
Read UDFs (for views) |
Write/act UDFs (button executor) |
task-management |
read, list_comments |
create, update_status, assign, add_comment, set_blocked_by, delete |
feedback-management |
inbox_view, list_cards, list_open_cards, get_card |
create_card, resolve_card, cancel_task_cards, acknowledge_feedback |
run-management |
read, transcript |
create, finish, set_prompt, fail_started |
agents-management |
read |
create, update, clone, delete, reset |
secrets-management |
list |
get, put, delete |
Example — a custom open-tasks board bound directly to the core task store:
{ "type": "sql-table", "props": {
"title": "Open tasks",
"sql": "select title, assignee, status from {{_core.task-management.read}} where status != 'completed' order by updatedAt desc" } }
A write needs a button whose executor fires the qualified name, e.g.
"executor": "_core.task-management.update_status?id=$selectedId&status='completed'".
Same rules as any {{ref}}: the UDF runs under the consuming widget's venv, so
core UDFs are kept dependency-light. Only the deployed-serve bundle can't reach
_core; every local surface can.
How "running" a widget works
A widget does not return a result value the way a py UDF does. Instead,
each data-bound node's sql is scanned → planned → resolved into rows:
- Plan — scan each
props.sql, stamp a stable queryId, harvest input
defaults, build the param → [queryId] dependency map.
- Resolve sources — map each
{{name}} to a UDF source on disk
(scripts/<name>/main.py).
- Resolve — build a self-contained resolver program (DuckDB runtime + the
per-query SQL + the resolved UDF sources) and run it through
ComputeBackend.execute — the same backend and content-addressed cache
as execute_code. In the sandbox: run each {{ref}} fresh → register a
DuckDB view → rewrite the SQL to those views → substitute $param → execute
→ encode rows. Returns { "data": {queryId: {columns, rows}}, "errors": {…} }.
List/dict-valued UDF columns. When a py UDF returns a DataFrame with
columns that contain Python lists or dicts (e.g. blockedBy: [] in task
records), the resolver JSON-encodes those cells before registering the DuckDB
view (so DuckDB can accept them as strings), then decodes them back to
arrays/objects in the output rows. This is transparent — the renderer receives
the original list/dict values, not strings.
Consequence for the local backend: because resolution runs through the
project's compute backend, the project venv must contain duckdb (plus
pandas/pyarrow, provided automatically) and any libraries the referenced
py UDFs import. Add them and sync before previewing:
cd ~/.openfused/workspaces/default/<project>
uv add duckdb pandas # + whatever the py UDFs import
uv sync
A missing/incomplete .venv surfaces a guided error telling you to run
uv sync. (project new does not create .venv.) Best practice: seed the
venv right after project new, not when the first resolve fails — see
fused-projects Step 2.
Live dashboards: refreshInterval (client-side polling)
A data-bound node can poll on its own — set props.refreshInterval to an
interval in milliseconds and the renderer re-resolves that source on a timer
(a map/fused-map node also accepts a per-layer refreshInterval under each
layer). Use it for live dashboards (a metric or chart that should track changing
UDF output without a human interaction).
{ "type": "metric", "props": {
"label": "Active runs", "refreshInterval": 5000,
"sql": "select count(*) as value from {{_core.run-management.read}} where status = 'started'" } }
- Floor is 1000 ms. Sub-floor values are clamped up to 1000 ms; a
non-positive / non-finite / non-number value is rejected (no timer scheduled).
- It's purely client-side — timers live in the renderer (the widget-host
viewer, the flow UI, and the deployed static bundle). The Python server, planner, and resolver
are unchanged: a tick is just an ordinary resolve POST for that source's
query, the same path a
$param change takes. Because of that it's deduped by
the cache — a tick landing inside the source's cache_max_age window returns
the stored entry with no recompute, so pair a short interval with a short cache
age (or 0s) if you actually want fresh data each tick.
verify never ticks (it's one-shot), so a refreshInterval has no effect on a
headless check — confirm live-refresh behavior on the real renderer.
Triggering a UDF on a button press (act, not just read)
The flow above is read — data flows UDF → rows → widget. A button can also
act: run a UDF on press via its executor prop.
{ "type": "button", "props": {
"label": "Promote",
"executor": "promote-udf?id=$selectedId&channel='release'"
} }
executor is a brace-less {{ref}} body — the SAME grammar as SQL: a UDF
name, optional ?k=v&k2=$param args, $param bound against the live params at
click time (single-quote literals as in SQL). Authoring it is just the
{{ref}} grammar above minus the braces. The UDF name is its project slug
(kebab-case, e.g. promote-udf), the same name a {{ref}} reads it under — or a
qualified _core.task-management.create to fire a built-in _core UDF (same
cross-project rule as reads).
- The press fires the UDF once (it is event-triggered, not reactive like a
$param change). The button shows a running state and disables mid-flight; an
error surfaces on hover. The UDF is invoked directly (the args become typed
kwargs — no SQL is synthesized), and its raw return value comes back as
data in the {data, error} envelope: a DataFrame/Arrow table as records, a
dict/list/scalar verbatim.
- It runs through the same compute backend as reads — so the project venv needs
the UDF's imports, same as the read path. Caching is forced off (a write must
re-run every press).
- Surface scope: works where there is a local host (
widget open, the
parley, the flow UI). On the deployed-serve bundle / MCP-Apps sandbox there is
no executor, so an executor press is a visible no-op (the button still
renders). Pair executor with action on the same button to ALSO report a
feedback event.
- v1 is fire-and-forget: the button does not yet auto-refresh dependent queries
from the UDF's result. To reflect a write, have the UDF mutate state a read
{{ref}} re-reads, and re-resolve (e.g. bump a $param the read depends on).
Self-verify a widget resolves (headless)
Confirm a widget resolves to data with fused widget verify — the fast
headless check (no GUI in the loop). Run it alongside open, not as a serial
gate in front of it (see Optimistic open + background verify):
it resolves the widget in one shot, prints the data envelope to
stdout, and exits — spawning and reaping nothing (no daemon, no port, no token,
no browser). It reuses the same resolution path as the render surfaces
in-process, so a clean verify faithfully predicts what open would render. It
is the headless counterpart of open/push/watch and replaces the old
hand-driven fused dev serve dance (spawn → read handshake → POST
/api/exec/widget → parse → kill).
# a .json config file, pinned to its project (the usual real-widget case)
fused widget verify scripts/<widget>/main.json \
--project-dir ~/.openfused/workspaces/default/<project>
# a saved widgets/<stem>.json owned by a project
fused widget verify <stem> --project <project>
# inline / from stdin; bind $param values; force a fresh run (or pin a max age)
cat main.json | fused widget verify --config -
fused widget verify <stem> --project <project> --params '{"region":"emea"}' --cache-refresh
fused widget verify <stem> --project <project> --cache-max-age 0s
The response is {"data": {queryId: {columns, rows}}, "errors": {…}, "depMap": {…}, "config": {…}, "warnings": […]}. Success = errors is empty and data
has rows. A
per-query failure (bad SQL, a UDF error, a missing {{ref}} source, a missing
$param) lands under errors[queryId] and never blanks the rest — read it to
fix the SQL or the referenced UDF; the command still exits 0 because
per-query failures are in-band. A source that returns [] (zero rows) is a
success, not an error: it's an empty result and the widget renders empty — a
clean empty widget, not an in-card error. Only a hard failure (bad input,
unknown/unresolvable widget, resolver crash) prints no stdout JSON, a message
on stderr, and exits non-zero. Resolution runs through the project's compute
backend, so the project venv needs duckdb + the py UDFs' deps (see above).
Full flags + the exit-code table: fused-cli (widget section).
The warnings array is a best-effort advisory — [{"type","props":[…]}, …],
empty [] when clean — flagging config props the server's catalog doesn't
recognize (a typo'd or unsupported prop name). It is strictly additive and
never changes the exit code (a warning is not an error), and because it comes
from the shared resolve path the interactive surfaces (open/push/watch)
carry it too. See the caveat below for what it does and doesn't catch.
Pick the right addressing mode
verify resolves {{ref}} sources in one of two modes, chosen by the flag you
pass. Picking the wrong one is the most common failure here — and it surfaces
as a misleading unknown endpoint error, not as "wrong mode", so know the rule
up front:
| Mode |
How to select |
What it can resolve |
Use when |
| Widget-dir |
a bare .json/--config with no --project/--project-dir (?dir=) |
Only the *.py under the widget file's own <dir>/udfs/. Cannot see other UDFs in the project's scripts/, and cannot resolve {{_core.*}} or any {{ref}} whose UDF lives elsewhere. |
The widget is self-contained — every {{ref}} resolves from its own udfs/ folder. |
| Project |
--project-dir <abs project root> (a .json/--config) or --project <name> (a stem) |
The whole project: every scripts/<name>/main.py, the project .venv, and the injected built-in _core workspace (so {{_core.task-management.read}} etc. resolve). |
The widget reads a UDF elsewhere in scripts/, a {{_core.*}} ref, or is a deployable scripts/<name>/main.json — i.e. almost any real project widget. This is the default. |
unknown endpoint ⇒ wrong mode (90% of the time). If a {{ref}} resolves
to unknown endpoint, you almost certainly ran a bare .json (widget-dir mode)
against a UDF that folder can't see. Add --project-dir <project root> (or
--project <name> for a stem) before suspecting the SQL or the UDF itself.
Being inside the project tree is not enough — the mode is set by the flag,
not by cwd. The reverse — a truly missing/misnamed {{ref}} — also reads
unknown endpoint, so confirm the mode first, then the name.
⚠ What this proves — and what it does NOT. A clean verify proves the
widget's SQL resolves to data, and its warnings array now catches props
the server catalog doesn't recognize (a typo'd or unsupported prop name).
But it still does not prove the renderer will honor a prop it does
recognize — verify returns raw rows and never renders. The gap it can't see is
version skew: a prop the server catalog knows but a stale/old @fusedio/flow
bundle doesn't (e.g. idColumn/parentColumn row-grouping added after that
bundle) resolves clean, warns nothing, and still draws wrong. So when you add a
newer config prop, don't trust the headless pass alone: confirm the prop is
actually applied on the real renderer (the widget-host viewer via fused widget open, the flow UI, or the deployed widget URL) and check the renderer version supports it. The headless check is a
data + catalog gate, not a render gate.
⚠ Build-freshness: verify sees source, open serves a compiled bundle.
verify resolves in-process against the source on disk, but open serves the
compiled widget-host bundle (widget-host/dist). If that bundle is stale
— older than its src/ — open can render old/wrong behavior while verify
stays green, and you'll burn a whole session debugging config that is already
correct. So when open misbehaves but verify is clean, suspect the running
artifact before your config: confirm the bundle is newer than its source (and
actually contains the symbol you're testing), rebuild (pnpm build in
widget-host), and re-open — before re-editing the widget. Attribute a render
bug to source only once you've confirmed the artifact you're running is fresh.
This is the general rule for any compiled surface: verify the thing you're
actually running, not the thing you're reading.
Getting a widget in front of a human — decision tree
CLI / standalone agent: open the widget yourself — do not tell the human to
open it. Once you have authored (and headlessly self-verified) the widget file,
you run the surface command — fused widget open <file> for a one-shot, or
push it into the parley for a standing loop. widget open launches/reuses the app
server, opens the browser, and blocks until the human responds, handing you their
reply. Never end your turn with "open the file in the flow UI to see it" — that
strands the human; drive the surface and bring back the result.
⚠ Agents spawned by the flow app (architect / data-analyst / QA
worker runs) MUST NOT run fused widget open / widget push / the parley.
Those surfaces wait on a human at a keyboard and would hang the run (or
collide with the already-running UI). In that context the widget flow is
different: a data-analyst just writes widgets/<stem>.json and the flow UI
live-renders it natively — no command to run; feedback is
asked via the teamwork MCP (ask_user — the single human-ask tool), and data-qa self-checks a
widget headless via POST /api/exec/widget. The "run widget open yourself" rule
is for an agent driving Fused from the CLI, not for an in-app worker.
All four surfaces render the same config through the same resolver. Pick
by the interaction you need:
| You want… |
Use |
Command |
Response back to the agent |
| One-shot feedback ("show this, tell me when done") |
widget open |
fused widget open scripts/sales-board/main.json |
Blocks until the human submits/closes (or --timeout, default 600s); prints the final $param state as one JSON line to stdout |
| A standing / long-running widget (push successive views, stream human events) |
parley |
widget push <cfg> + widget watch (or widget agent) |
NDJSON event stream (action/close); the view persists on /parley, push a new one and keep watching. Consume watch via a Monitor (not a background task) so each event wakes you. See Long-running / standing widgets. |
| A comment-driven revise loop (human pins comments on the widget; an agent edits the file) |
parley + widget agent |
widget push <file.json> (or --project-dir <root>) in one terminal, widget agent in another |
The human comments on the file-backed parley page (no flow app); widget agent turns each comment into a file edit + re-push. See fused-feedback → CLI-native comment feedback. |
| The flow UI (browse saved dashboards) — separate tool, out of scope |
flow |
flow (or npx @fusedio/flow once published) |
Human-driven. Only widgets/<stem>.json live-renders — a json UDF shows as source. |
| A shareable URL (stakeholder, no local server) |
deploy |
fused udf deploy sales-board --project <p> |
A rendered widget URL (preview → promote to release) |
- Finding the target (don't
ls the workspace). When the human just names a
widget and/or project ("open the session_cost widget of cc-open"), resolve
it with the CLI, not by listing directories:
fused project list → JSON array of every project (name, path, exists).
Skip it when the human already named the project.
fused project show <project> → includes a "widgets": [...] array of the
project's saved-widget stems (the widgets/<stem>.json you'd pass to
open), plus its references and components. One call confirms the project
and lists its widgets — replacing a ls ~/.openfused/workspaces/… + ls widgets/ walk with one structured command. When the human named both the
project and an obvious widget, skip discovery entirely and go straight to
fused widget open <stem> --project <project>.
widget open <target>: <target> is a .json path or a saved-widget stem.
It launches/reuses the app server, opens the browser, and blocks. --no-open
still blocks (prints the URL). This is the canonical "ask the human" path for
an agent. The block is time-bounded: --timeout seconds (default 600 =
10 min), after which it prints {"action":"timeout"} and exits 3;
--timeout 0 waits forever (see Long-running / standing widgets).
-c/--config TEXT (or --config - for stdin): pass the widget config
inline instead of a <target> — one call instead of Write a .json
then open it. Mutually exclusive with <target>. Best for one-shot
asks (approvals, questions) that settle and exit; the config is not
editable (no durable file), so for an edit-and-refresh loop keep authoring a
named .json and open its path. widget push -c/--config is the parley
counterpart (no temp file; --source PATH sets the edit anchor).
--project-dir PATH (.json/--config targets only; on both widget open and widget push): pins fused dev serve's ?projectDir= mode to a
project directory so UDFs in scripts/ and the project .venv are available.
Pass this for almost any real project widget — one that references a UDF
elsewhere in scripts/ or a {{_core.*}} ref. Omitting it leaves the surface in
widget-dir (?dir=) mode, which sees only the .py files sitting next to the
widget file; a {{ref}} to anything else then fails (often a misleading unknown endpoint/ValueError). Being inside the project tree is not enough — the mode
is set by this flag, not by file location
(see Pick the right addressing mode).
Mutually exclusive with --project. On push, it is the entry point to
feedback mode: a .json path pushed --project-dir resolves against the
project and stays file-backed, so the parley comment loop works on a
scripts/-backed widget — the only push form that is both project-addressed and
editable (a {project, stem} push resolves but is not editable). See
fused-feedback → CLI-native comment feedback.
- parley (
widget push/watch/parley/agent) is the standing
agent↔human channel — successive views land on one persistent page and the
human's events stream back as NDJSON. Use it for iterative refinement. Details
- flags:
fused-cli (widget section).
- The flow UI (separate tool, out of scope) renders saved dashboards
(
widgets/<stem>.json) natively, with data resolved through fused dev serve. A
json UDF (scripts/<name>/main.json) is not live-rendered there — it
appears as source/spec; preview a json UDF with widget open (above) or deploy it.
- Deployed
json UDFs build a self-contained widget.html bundle + a gated
resolver data route → a stable widget URL. This is the one place a renderer
bundle (not the app) serves the widget.
Long-running / standing widgets
Yes — a widget can stay up long-term, but widget open is the wrong tool for
it. An open session lives only as long as its command runs: the block
gives up after --timeout seconds (default 600 = 10 min → {"action":"timeout"},
exit 3), and on any exit (timeout, Ctrl-C, the tab closing) it de-registers
the widget — so you cannot "open it and walk away." Pick by what "long-running"
means:
- One long-lived ask that just needs more patience —
widget open --timeout 0
(wait forever). The block never expires; it returns only when the human acts (or
you interrupt). Because it then blocks indefinitely, you MUST drive it from
run_in_background and Monitor its stderr for the widget page: URL — never
a foreground --timeout 0 you can't get back from (same rule as
Optimistic open + background verify).
- A widget that genuinely persists across a long session — the parley (the
real long-running surface). Unlike an
open session, a pushed parley view
persists in the widget-host independently of the short-lived push command:
widget push <cfg> posts a view and exits immediately, the view stays on the
standing /parley page, and widget watch (its --timeout defaults to 0 =
watch forever) streams the human's events as NDJSON. Push successive views onto
the same page over time. This is the surface to reach for when the widget must
outlive a single blocking call.
Either way, the widget-host itself is a detached background process that
outlives the CLI call — it binds a fixed loopback port (default 4410) and a
later widget command reuses it — so the host (and any parley view on it) keeps
serving between commands. What's transient is the per-open session, not the
host.
Optimistic open + background verify
widget open blocks until the human responds, so you cannot both open a
widget and do anything else from the same foreground call. Two rules follow — and
together they kill the blind-sleep anti-pattern.
Open optimistically; verify in the background — don't gate the human's view on
the headless check. verify is a data + catalog gate, not a render gate, and
it's fast. Launch open first so the human sees the widget the moment the server
is up, and run verify concurrently to catch resolve/catalog problems — then
react (push a fix, flag an empty result) if it comes back dirty. Serialising
verify → open only adds latency to the human's first paint for a check that
rarely fails once the SQL is written.
Poll for readiness — never blind-sleep. Because open blocks, start it
with run_in_background and watch its output for the http://… URL instead of
guessing with sleep 8; cat log. A fixed sleep is both too slow (you pay the
worst case every time) a
…(truncated)
1---2name: fused-widgets3description: Authoring and previewing JSON-UI widgets as the response of running a project — the py-UDF-computes → json-widget-visualizes pattern, the {{ref}}/$param data grammar, how resolution runs through the compute backend, and the CLI surfaces (widget open, parley, deployed URL) that put a rendered widget in front of a human. Use whenever the desired output of a UDF/project is a widget, not raw data.4---56# Widgets — getting a rendered result back78> **Part of the Fused skill set — don't work from it alone.** Fused is `workspace ⊃9> project ⊃ UDF`. Before acting, also load **`fused-projects`** for the end-to-end10> model (creating, running, opening, and deploying a project's widget). For a11> question/approval/plan UI load **`fused-feedback`**. See **`fused-guide`** for the12> full set — this skill assumes that context.1314In most Fused flows the thing a human wants back is a **widget** (an15interactive dashboard), not a raw value. A widget is a JSON config; running it16yields *resolved rows that a renderer turns into a visual*. This skill covers17authoring widgets and getting them in front of a human.1819**There are NO MCP widget tools.** Agents author widget *files*; humans *view*20them through the CLI (`fused widget open` / the parley). The separate **flow** UI21(`fusedio/flow` — started with the `flow` CLI, or `npx @fusedio/flow` once22published) can also render them, but driving flow is **out of scope** for this23skill; everything below is the `fused` CLI, never MCP. (See24`fused-projects` for where this sits in the project lifecycle, and25`fused-cli` for the full `widget` command flag tables.)2627---2829## What a widget is3031A widget is a single JSON document — a tree of nodes, each32`{ "type": "<component>", "props": { … }, "children": [ … ] }`. It lives in one33of two places in a project — **and the two render on different surfaces, so the34choice matters:**3536- **`widgets/<stem>.json`** — a **saved project dashboard**, addressed by its37 **stem** (`fused widget open <stem> --project <p>`). This is also the form the38 separate **flow** UI live-renders on its project surface (out of scope here).39 **Use this form when the human will browse the dashboard.**40- **`scripts/<name>/main.json`** — a **`json`-kind UDF**: a first-class, **deployable**41 entrypoint (deploys to a stable widget URL). Preview it rendered with42 `fused widget open scripts/<name>/main.json` (the file render path) or by43 deploying it. In the flow UI it appears as **source** ("View source ↗"), **not**44 a live render.4546> The config is identical either way (same `{{ref}}`/`$param` grammar), so this is47> purely *where the file lives*. The default for "show the human a dashboard" is48> `widgets/<stem>.json`. If you also need a shareable URL, keep the deployable copy49> at `scripts/<name>/main.json` as well.5051> **Always pair a widget with a spec.** For every `widgets/<stem>.json` you save,52> write a `widgets/<stem>.spec.md` contract sidecar next to it — purpose, the53> `{{udf}}` data it binds, and the components + their SQL — the SAME spec↔file54> pairing a UDF has (`scripts/<stem>/spec.md`). The app renders it in the widget's55> **Preview ⇄ Spec** toggle, and `data-qa` verifies the widget against it; a widget56> without its `spec.md` is incomplete.5758The renderable component set is fixed and comes from a single generated source59of truth (`widgets/components.json`); a `type` outside it is a hard error. The60current components:6162| Group | Components |63|---|---|64| Layout / display | `div`, `form`, `text`, `markdown`, `diff`, `html`, `image`, `video`, `iframe`, `canvas`, `button`, `metric` |65| Charts | `bar-chart`, `line-chart`, `scatter-chart`, `donut-chart`, `heatmap-chart`, `stacked-bar-chart`, `stacked-area-chart` |66| Data | `sql-table` |67| **Inputs** (write a `$param`) | `dropdown`, `checkbox-group`, `slider`, `number-input`, `text-input`, `text-area`, `datetime-input`, `color-input`, `file-upload`, `camera-input`, `gallery-input` |68| **Feedback primitives** (Fused-owned; carry intent beyond a scalar value — never SQL-referenced) | `button`, `video-review`, `canvas`, `task-board` |69| **Source** | `sql-runner` |7071Input components carry a `param` prop and a `defaultValue`; they seed the param72store on first paint and re-resolve dependent queries when the human changes73them.7475> **Use this catalog — not the external Fused-branded skills.** Author widgets76> ONLY against this catalog (the `agent_core` widget set). Do **not** use any77> external Fused-branded skill (`fused:*`, e.g. `fused:json-ui-schemas`,78> `fused:canvas-toml`) or the hosted Fused product's JSON-UI schema — those target79> a different product and carry components this catalog does not support, and an80> unsupported `type` is a hard render error. The live,81> authoritative list for the project you're in is the `widget_components` array in82> `get_project_context` (`[{type, hasChildren, isInput}, …]`) — orient on it.8384### Component reference (key props — `style` applies to all, omit unless overriding)8586Charts read **fixed result columns** (alias your SELECT to them), not props:8788| type | reads columns | key props |89|---|---|---|90| `bar-chart` | `label`, `value` | `sql`, `title`, `barColor`, `horizontal`, `showValues`, `xAxisLabel`, `yAxisLabel` |91| `line-chart` | `label`, `value`, opt `series` | `sql`, `title`, `lineColor`, `curveType`, `showArea`, `xAxisLabel`, `yAxisLabel` |92| `stacked-area-chart` | `label`, `value`, opt `series` | `sql`, `title`, `curveType`, `showLegend`, `showBrush`, `xAxisLabel`, `yAxisLabel` |93| `stacked-bar-chart` | `label`, `value`, opt `series` | `sql`, `title`, `horizontal`, `showLegend`, `barColor`, `xAxisLabel`, `yAxisLabel` |94| `donut-chart` | name, value | `sql`, `title`, `innerRadius`, `showLabels`, `showCenterTotal` |95| `scatter-chart` | `x`, `y`, opt `series`/`size`/`label` | `sql`, `title`, `pointColor`, `xLabel`, `yLabel` |96| `heatmap-chart` | `x`, `y`, `value` | `sql`, `title`, `lowColor`, `highColor`, `showValues` |9798**Always title chart axes.** Set `xAxisLabel`/`yAxisLabel` on the cartesian charts above99(and `xLabel`/`yLabel` on `scatter-chart`) so each axis names what it shows — an unlabeled100axis is an incomplete chart. These are axis *titles*, separate from the per-tick labels.101102Display / data:103104| type | key props |105|---|---|106| `text` | `value` / `sql`, `variant` (default/muted/small/large/h1–h4) — a single value |107| `markdown` | `value` / `sql` — GitHub-flavored prose (headings/lists/tables/code) |108| `diff` | `before` + `after` (computed) OR `diff` (unified string), `oldLabel`, `newLabel` |109| `metric` | `value` / `sql`, `label`, `format`, `prefix`, `suffix`, `decimals` |110| `sql-table` | `sql`, `title`, `sortable`, `filterable`, `maxRows`; tree-view pair `idColumn` + `parentColumn` (rows nest by parent id). No `hiddenColumns`/`columnVisibility` — select only the columns you want in the `sql`. |111| `image` / `video` | `src` (+ `alt`/`poster`/`controls`…) |112| `html` | `value` (raw HTML — escape hatch; prefer `markdown`) |113| `iframe` | `src` (absolute http(s)), `title`, `allow` |114| `div` / `form` | `style` (layout container; `children`) |115| `map` | `layers` (UDF geometry), `mapStyle`, `centerLng`, `centerLat`, `zoom`, `param`, `sendParam` — simple geometry |116| `fused-map` | `layers` (deck.gl: scatterplot/geojson/h3/heatmap/arc/mvt/raster), `basemap`, `centerLng`, `centerLat`, `zoom`, `param`, `showLegend`, `showLayerPanel`, `showBasemapSwitcher` — advanced |117| `map-bounds` | `param`, `centerLng`, `centerLat`, `zoom`, `mapStyle`, `autoSend`, `buttonLabel` — viewport-as-input only (no data) |118| `sql-runner` | `name`, `sql` — server-side **source** container (runs a named query once, exposes it to descendants as `{{name}}`); not a rendered output. Renders everywhere (no heavy deps). |119120> **Maps render in the native renderer only.** `map`, `map-bounds`, and `fused-map`121> need heavy WebGL deps + external tiles the self-contained deployed bundle does122> not ship, so the deployed build aliases the map modules to a **placeholder** —123> they render only in the native renderer (`fused widget open` / the parley / the124> flow UI), not on a deployed URL.125126Inputs (write `param`; all take `param`, `label`, `defaultValue`):127128| type | extra key props |129|---|---|130| `dropdown` | `options` `[{value,label}]` (REQUIRED, non-empty), opt `sql` |131| `checkbox-group` | `options` `[{value,label}]` — multi-select (writes an array) |132| `slider` | `min`, `max`, `step` |133| `number-input` / `text-input` / `text-area` | `placeholder` (+ `rows` for text-area) |134| `datetime-input` / `color-input` | — |135| `button` | `label`, `action`, `submit`, `variant` |136137Prose vs raw HTML vs scalar: use **`text`** for a single value (a label, a count),138**`markdown`** for prose / reports / notes (headings, lists, tables, code — GitHub139flavored), and **`html`** only when you need raw HTML. Use **`diff`** to show the140change between two texts — pass `before` + `after` (the line diff is computed for141you) or a precomputed unified-diff string in `diff`; built for reviewing markdown142spec changes. Lean on the built-in defaults — omit `style` unless you need a143deliberate override (one consistent default look across every surface).144145> **Don't inline a large dataset into an `html` `value`** — keep large data outside146> the widget, where the project stores its data, and load it at runtime. Concretely:147> store the data as a file in `assets/`, add a backing UDF in `scripts/` that reads148> it with `openfused.asset_path(...)`, and bind the widget to it via `{{udf_name}}`149> in a `sql` prop. A widget config over **512 KB** is rejected at save and resolve150> time (`ConfigTooLargeError`) — an inlined dataset also rides in every resolve151> request, which the app server caps at 1 MB.152153> **`checkbox-group` is the multi-select reply channel** — the array twin of154> `dropdown`. It is the input to reach for when an ask allows **more than one**155> answer (an `ask_user` widget whose question allows more than one choice). It156> writes the chosen option157> `value`s to its `param` as a **`string[]`**. Because the param holds an array,158> it (like `sql-table`'s `selectionParam` and `video-review`) **must never be159> referenced in SQL** — `$param` is text substitution and only scalars are160> SQL-safe. Use a `dropdown` for single-select.161162---163164## The core pattern: a `py` UDF computes, a `json` widget visualizes165166This is the shape of almost every project. A `py` UDF returns a DataFrame; a167`json` widget binds to it in a DuckDB SQL string via the `{{ref}}` grammar.168169**`scripts/sales/main.py`** — the data UDF:170171```python172import fused173174@fused.udf175def main(region: str = "all"):176 import pandas as pd177 df = pd.read_parquet("s3://my-bucket/sales.parquet")178 if region != "all":179 df = df[df["region"] == region]180 return df[["month", "region", "revenue"]]181```182183**`widgets/sales-board.json`** — the saved dashboard that renders it (use184`scripts/sales-board/main.json` instead if this widget is a deployable entrypoint):185186```json187{188 "type": "div",189 "props": { "style": "display: grid; gap: 16px; padding: 16px" },190 "children": [191 { "type": "text", "props": { "value": "Revenue across regions", "variant": "h3" } },192 { "type": "dropdown", "props": {193 "param": "region", "label": "Region",194 "options": [{"value": "all", "label": "All"}, {"value": "emea", "label": "EMEA"}],195 "defaultValue": "all" } },196 { "type": "slider", "props": {197 "param": "min_revenue", "label": "Min revenue",198 "min": 0, "max": 500, "defaultValue": 0 } },199 { "type": "metric", "props": {200 "label": "Total revenue", "format": "currency", "prefix": "$",201 "sql": "select sum(revenue) as value from {{sales?region=$region}}" } },202 { "type": "bar-chart", "props": {203 "sql": "select month, sum(revenue) as revenue from {{sales?region=$region}} where revenue >= $min_revenue group by month order by month" } },204 { "type": "sql-table", "props": {205 "title": "Raw rows",206 "sql": "select month, region, revenue from {{sales?region=$region}} where revenue >= $min_revenue order by month limit 50" } }207 ]208}209```210211The `dropdown`/`slider` write `$region`/`$min_revenue`; the `metric`,212`bar-chart`, and `sql-table` read them and re-resolve when they change.213214**Reading a bundled project asset** (a file under the project's `assets/`): use215`openfused.asset_path(...)`, never a relative `./assets/...`, a `__file__`-relative216path, or a hard-coded absolute path. A UDF runs in a throwaway sandbox cwd, so only217a project-root-anchored path resolves — and the widget *render* path is exactly where218the relative forms silently break (blank dashboard / `FileNotFoundError`):219220```python221import openfused222df = pd.read_parquet(openfused.asset_path("data.parquet")) # robust, one-shot223```224225**Geo data → always a map.** When the data has a geographic dimension (lat/lon,226geometry, or geocodable place/address columns), include a `fused-map` in the227dashboard by default — unless the user explicitly says not to. A map is the default228for geo data, not an optional extra.229230### The `{{ref}}` / `$param` grammar (essentials)231232- `{{sales}}` — the whole result of the `sales` UDF, default params.233- `{{sales?region=$region}}` — pass kwarg `region` bound to the param `$region`.234- `{{sales?region=emea&limit=50}}` — bare literals (string `emea`, number `50`);235 single-quote a value that must stay a string or contain `& = ` whitespace:236 `{{sales?region='emea, west'}}`.237- `name` may contain `-` (`{{my-udf}}`). Two refs with identical name+args share238 one UDF run / one DuckDB view; distinct args run the UDF again.239- **Cross-project (`{{_core.proj.udf}}`)** — a bare `{{udf}}` resolves in the240 current project; a three-segment ref reuses a UDF from the built-in `_core`241 workspace, e.g. `{{_core.task-management.create}}` (read path) or a button242 `executor` firing the same name (write path). Only the allowlisted `_core`243 workspace is reachable (any other first segment → `unknown endpoint`); its244 source is the packaged `core_source_dir()` but it RUNS under the245 **consuming** project's venv, so keep shared UDFs dependency-light. `_core` refs246 resolve on every local surface — they all route through `fused dev serve`,247 whose directory-addressed modes (`?dir=`/`?projectDir=`, used by `widget open` /248 parley) inject the built-in `_core` shared root by default — so a standalone249 widget (e.g. the task-board's `mutateBackend: "core"`) can drive the built-in250 `_core` UDFs; only the deployed-serve bundle can't.251 App-parity note: this is an extension specific to this platform (`agent_core`) —252 a cross-project ref won't resolve if pasted into the external Fused app.253- `$name` is an **inline text substitution** (not a DuckDB bind param), so it254 works anywhere — including inside `'…'` and `"…"`. Grammar: `$[A-Za-z_]\w*`.255 Substitution is context-aware (quotes are doubled; comments/dollar-quoted256 bodies are left verbatim). This matches the Fused app byte-for-byte, so a257 config authored here pastes into the app and behaves identically.258259The `{{ref}}` / `$param` grammar above is the full contract.260261> **Archived widgets (soft-deleted, reachable by link).** When a human archives a262> widget in the app it moves to `archive/widgets/<stem>.json` and is **read-only** —263> gone from the canvas, the Widget tab, and `get_project_context`, but still loadable264> through a **task deep-link**. A bare `{{udf}}` resolves from the live `scripts/`265> dir; if that UDF was *also* archived, the resolver falls back to266> `archive/scripts/<udf>/` so a fully-archived **widget + UDF** pair still resolves its267> data when opened via the link. The fallback fires only for the **exact ref** the268> widget names — archived UDFs are never enumerated, so they stay out of every catalog.269> You don't author or restore archived widgets from here (that's an app + human270> action); just know an archived widget you reach by link still renders.271272### Core UDFs → custom views (no project setup)273274The built-in `_core` workspace ships ready-to-use UDFs that **any ad-hoc widget can275read or act on directly** via a `{{_core.<project>.<udf>}}` ref (read) or a button276`executor` firing the same name (write). This is how a user gets a **custom view**277of Fused's own state — a bespoke task board, an inbox triage panel, a run278monitor — without authoring any backing UDF: bind a chart/table straight to a279core UDF. No setup, no env, no `uv add` for the core source itself — the280directory-addressed surfaces (`widget open`, parley, `fused dev serve`) inject281the `_core` root automatically.282283The shipped `_core` projects and their UDFs:284285| Project | Read UDFs (for views) | Write/act UDFs (button `executor`) |286|---|---|---|287| `task-management` | `read`, `list_comments` | `create`, `update_status`, `assign`, `add_comment`, `set_blocked_by`, `delete` |288| `feedback-management` | `inbox_view`, `list_cards`, `list_open_cards`, `get_card` | `create_card`, `resolve_card`, `cancel_task_cards`, `acknowledge_feedback` |289| `run-management` | `read`, `transcript` | `create`, `finish`, `set_prompt`, `fail_started` |290| `agents-management` | `read` | `create`, `update`, `clone`, `delete`, `reset` |291| `secrets-management` | `list` | `get`, `put`, `delete` |292293Example — a custom open-tasks board bound directly to the core task store:294295```json296{ "type": "sql-table", "props": {297 "title": "Open tasks",298 "sql": "select title, assignee, status from {{_core.task-management.read}} where status != 'completed' order by updatedAt desc" } }299```300301A write needs a `button` whose `executor` fires the qualified name, e.g.302`"executor": "_core.task-management.update_status?id=$selectedId&status='completed'"`.303Same rules as any `{{ref}}`: the UDF runs under the **consuming** widget's venv, so304core UDFs are kept dependency-light. Only the deployed-serve bundle can't reach305`_core`; every local surface can.306307---308309## How "running" a widget works310311A widget does **not** return a `result` value the way a `py` UDF does. Instead,312each data-bound node's `sql` is **scanned → planned → resolved into rows**:3133141. **Plan** — scan each `props.sql`, stamp a stable `queryId`, harvest input315 defaults, build the `param → [queryId]` dependency map.3162. **Resolve sources** — map each `{{name}}` to a UDF source on disk317 (`scripts/<name>/main.py`).3183. **Resolve** — build a self-contained resolver program (DuckDB runtime + the319 per-query SQL + the resolved UDF sources) and run it through320 **`ComputeBackend.execute`** — the *same backend and content-addressed cache321 as `execute_code`*. In the sandbox: run each `{{ref}}` fresh → register a322 DuckDB view → rewrite the SQL to those views → substitute `$param` → execute323 → encode rows. Returns `{ "data": {queryId: {columns, rows}}, "errors": {…} }`.324325**List/dict-valued UDF columns.** When a `py` UDF returns a DataFrame with326columns that contain Python lists or dicts (e.g. `blockedBy: []` in task327records), the resolver JSON-encodes those cells before registering the DuckDB328view (so DuckDB can accept them as strings), then decodes them back to329arrays/objects in the output rows. This is transparent — the renderer receives330the original list/dict values, not strings.331332**Consequence for the local backend:** because resolution runs through the333project's compute backend, the project venv must contain `duckdb` (plus334`pandas`/`pyarrow`, provided automatically) **and** any libraries the referenced335`py` UDFs import. Add them and sync before previewing:336337```sh338cd ~/.openfused/workspaces/default/<project>339uv add duckdb pandas # + whatever the py UDFs import340uv sync341```342343A missing/incomplete `.venv` surfaces a guided error telling you to run344`uv sync`. (`project new` does **not** create `.venv`.) Best practice: seed the345venv right after `project new`, not when the first resolve fails — see346`fused-projects` Step 2.347348### Live dashboards: `refreshInterval` (client-side polling)349350A data-bound node can **poll** on its own — set `props.refreshInterval` to an351interval in **milliseconds** and the renderer re-resolves that source on a timer352(a `map`/`fused-map` node also accepts a per-layer `refreshInterval` under each353layer). Use it for live dashboards (a metric or chart that should track changing354UDF output without a human interaction).355356```json357{ "type": "metric", "props": {358 "label": "Active runs", "refreshInterval": 5000,359 "sql": "select count(*) as value from {{_core.run-management.read}} where status = 'started'" } }360```361362- **Floor is 1000 ms.** Sub-floor values are clamped up to 1000 ms; a363 non-positive / non-finite / non-number value is rejected (no timer scheduled).364- It's **purely client-side** — timers live in the renderer (the widget-host365 viewer, the flow UI, and the deployed static bundle). The Python server, planner, and resolver366 are **unchanged**: a tick is just an ordinary resolve POST for that source's367 query, the same path a `$param` change takes. Because of that it's **deduped by368 the cache** — a tick landing inside the source's `cache_max_age` window returns369 the stored entry with no recompute, so pair a short interval with a short cache370 age (or `0s`) if you actually want fresh data each tick.371- `verify` never ticks (it's one-shot), so a `refreshInterval` has no effect on a372 headless check — confirm live-refresh behavior on the real renderer.373374---375376## Triggering a UDF on a button press (act, not just read)377378The flow above is **read** — data flows UDF → rows → widget. A `button` can also379**act**: run a UDF *on press* via its `executor` prop.380381```json382{ "type": "button", "props": {383 "label": "Promote",384 "executor": "promote-udf?id=$selectedId&channel='release'"385} }386```387388- `executor` is a **brace-less `{{ref}}` body** — the SAME grammar as SQL: a UDF389 name, optional `?k=v&k2=$param` args, `$param` bound against the live params at390 click time (single-quote literals as in SQL). Authoring it is just the391 `{{ref}}` grammar above minus the braces. The UDF name is its **project slug**392 (kebab-case, e.g. `promote-udf`), the same name a `{{ref}}` reads it under — or a393 qualified `_core.task-management.create` to fire a built-in `_core` UDF (same394 cross-project rule as reads).395- The press fires the UDF **once** (it is event-triggered, not reactive like a396 `$param` change). The button shows a running state and disables mid-flight; an397 error surfaces on hover. The UDF is **invoked directly** (the args become typed398 kwargs — no SQL is synthesized), and its **raw return value** comes back as399 `data` in the `{data, error}` envelope: a DataFrame/Arrow table as records, a400 dict/list/scalar verbatim.401- It runs through the same compute backend as reads — **so the project venv needs402 the UDF's imports**, same as the read path. Caching is forced off (a write must403 re-run every press).404- **Surface scope:** works where there is a local host (`widget open`, the405 parley, the flow UI). On the deployed-serve bundle / MCP-Apps sandbox there is406 no executor, so an `executor` press is a visible no-op (the button still407 renders). Pair `executor` with `action` on the same button to ALSO report a408 feedback event.409- v1 is fire-and-forget: the button does not yet auto-refresh dependent queries410 from the UDF's result. To reflect a write, have the UDF mutate state a read411 `{{ref}}` re-reads, and re-resolve (e.g. bump a `$param` the read depends on).412413414---415416## Self-verify a widget resolves (headless)417418Confirm a widget resolves to data with **`fused widget verify`** — the fast419headless check (no GUI in the loop). Run it *alongside* `open`, not as a serial420gate in front of it (see [Optimistic open + background verify](#optimistic-open--background-verify)):421it resolves the widget in **one shot**, prints the data envelope to422stdout, and exits — spawning and reaping nothing (no daemon, no port, no token,423no browser). It reuses the same resolution path as the render surfaces424in-process, so a clean `verify` faithfully predicts what `open` would render. It425is the headless counterpart of `open`/`push`/`watch` and **replaces** the old426hand-driven `fused dev serve` dance (spawn → read handshake → POST427`/api/exec/widget` → parse → kill).428429```sh430# a .json config file, pinned to its project (the usual real-widget case)431fused widget verify scripts/<widget>/main.json \432 --project-dir ~/.openfused/workspaces/default/<project>433434# a saved widgets/<stem>.json owned by a project435fused widget verify <stem> --project <project>436437# inline / from stdin; bind $param values; force a fresh run (or pin a max age)438cat main.json | fused widget verify --config -439fused widget verify <stem> --project <project> --params '{"region":"emea"}' --cache-refresh440fused widget verify <stem> --project <project> --cache-max-age 0s441```442443The response is `{"data": {queryId: {columns, rows}}, "errors": {…}, "depMap":444{…}, "config": {…}, "warnings": […]}`. **Success = `errors` is empty and `data`445has rows.** A446per-query failure (bad SQL, a UDF error, a missing `{{ref}}` source, a missing447`$param`) lands under `errors[queryId]` and never blanks the rest — read it to448fix the SQL or the referenced UDF; the command **still exits `0`** because449per-query failures are *in-band*. A source that returns `[]` (zero rows) is a450**success**, not an error: it's an empty result and the widget renders empty — a451clean empty widget, not an in-card error. Only a **hard** failure (bad input,452unknown/unresolvable widget, resolver crash) prints **no stdout JSON**, a message453on stderr, and exits **non-zero**. Resolution runs through the project's compute454backend, so the project venv needs `duckdb` + the py UDFs' deps (see above).455Full flags + the exit-code table: `fused-cli` (widget section).456457The `warnings` array is a **best-effort advisory** — `[{"type","props":[…]}, …]`,458empty `[]` when clean — flagging config props the server's catalog doesn't459recognize (a typo'd or unsupported prop name). It is strictly additive and460**never changes the exit code** (a warning is not an error), and because it comes461from the shared resolve path the interactive surfaces (`open`/`push`/`watch`)462carry it too. See the caveat below for what it does and doesn't catch.463464### Pick the right addressing mode465466`verify` resolves `{{ref}}` sources in one of two modes, chosen by the flag you467pass. **Picking the wrong one is the most common failure here** — and it surfaces468as a misleading `unknown endpoint` error, not as "wrong mode", so know the rule469up front:470471| Mode | How to select | What it can resolve | Use when |472|---|---|---|---|473| **Widget-dir** | a bare `.json`/`--config` with **no** `--project`/`--project-dir` (`?dir=`) | Only the `*.py` under the widget file's own `<dir>/udfs/`. **Cannot** see other UDFs in the project's `scripts/`, and **cannot** resolve `{{_core.*}}` or any `{{ref}}` whose UDF lives elsewhere. | The widget is self-contained — every `{{ref}}` resolves from its own `udfs/` folder. |474| **Project** | `--project-dir <abs project root>` (a `.json`/`--config`) or `--project <name>` (a stem) | The whole project: every `scripts/<name>/main.py`, the project `.venv`, and the injected built-in `_core` workspace (so `{{_core.task-management.read}}` etc. resolve). | The widget reads a UDF elsewhere in `scripts/`, a `{{_core.*}}` ref, or is a deployable `scripts/<name>/main.json` — i.e. almost any real project widget. **This is the default.** |475476> **`unknown endpoint` ⇒ wrong mode (90% of the time).** If a `{{ref}}` resolves477> to `unknown endpoint`, you almost certainly ran a bare `.json` (widget-dir mode)478> against a UDF that folder can't see. Add `--project-dir <project root>` (or479> `--project <name>` for a stem) before suspecting the SQL or the UDF itself.480> Being *inside* the project tree is **not** enough — the mode is set by the flag,481> not by cwd. The reverse — a truly missing/misnamed `{{ref}}` — also reads482> `unknown endpoint`, so confirm the mode first, then the name.483484> **⚠ What this proves — and what it does NOT.** A clean `verify` proves the485> widget's SQL **resolves to data**, and its `warnings` array now catches props486> the **server catalog** doesn't recognize (a typo'd or unsupported prop name).487> But it still does **not** prove the renderer will **honor** a prop it *does*488> recognize — `verify` returns raw rows and never renders. The gap it can't see is489> **version skew**: a prop the server catalog knows but a **stale/old `@fusedio/flow`490> bundle** doesn't (e.g. `idColumn`/`parentColumn` row-grouping added after that491> bundle) resolves clean, warns nothing, and still draws wrong. So when you add a492> **newer config prop**, don't trust the headless pass alone: confirm the prop is493> actually applied on the real renderer (the widget-host viewer via `fused widget494> open`, the flow UI, or the deployed widget URL) and check the renderer version supports it. The headless check is a495> data + catalog gate, not a render gate.496497> **⚠ Build-freshness: verify sees source, `open` serves a compiled bundle.**498> `verify` resolves in-process against the source on disk, but `open` serves the499> **compiled `widget-host` bundle** (`widget-host/dist`). If that bundle is stale500> — older than its `src/` — `open` can render old/wrong behavior while `verify`501> stays green, and you'll burn a whole session debugging config that is already502> correct. So when `open` misbehaves but `verify` is clean, **suspect the running503> artifact before your config**: confirm the bundle is newer than its source (and504> actually contains the symbol you're testing), rebuild (`pnpm build` in505> `widget-host`), and re-open — *before* re-editing the widget. Attribute a render506> bug to source only once you've confirmed the artifact you're running is fresh.507> This is the general rule for any compiled surface: verify the thing you're508> actually running, not the thing you're reading.509510---511512## Getting a widget in front of a human — decision tree513514> **CLI / standalone agent: open the widget yourself — do not tell the human to515> open it.** Once you have authored (and headlessly self-verified) the widget file,516> *you* run the surface command — `fused widget open <file>` for a one-shot, or517> push it into the parley for a standing loop. `widget open` launches/reuses the app518> server, opens the browser, and blocks until the human responds, handing you their519> reply. Never end your turn with "open the file in the flow UI to see it" — that520> strands the human; drive the surface and bring back the result.521>522> **⚠ Agents spawned by the flow app (architect / data-analyst / QA523> worker runs) MUST NOT run `fused widget open` / `widget push` / the parley.**524> Those surfaces wait on a human at a keyboard and would **hang the run** (or525> collide with the already-running UI). In that context the widget flow is526> different: a `data-analyst` just **writes `widgets/<stem>.json`** and the flow UI527> **live-renders it natively** — no command to run; feedback is528> asked via the teamwork MCP (`ask_user` — the single human-ask tool), and `data-qa` self-checks a529> widget headless via `POST /api/exec/widget`. The "run `widget open` yourself" rule530> is for an agent driving Fused from the CLI, not for an in-app worker.531532All four surfaces render the **same config** through the **same resolver**. Pick533by the interaction you need:534535| You want… | Use | Command | Response back to the agent |536|---|---|---|---|537| **One-shot** feedback ("show this, tell me when done") | `widget open` | `fused widget open scripts/sales-board/main.json` | Blocks until the human submits/closes (or `--timeout`, default 600s); prints the final `$param` state as one JSON line to stdout |538| **A standing / long-running widget** (push successive views, stream human events) | **parley** | `widget push <cfg>` + `widget watch` (or `widget agent`) | NDJSON event stream (`action`/`close`); the view persists on `/parley`, push a new one and keep watching. Consume `watch` via a **`Monitor`** (not a background task) so each event wakes you. See [Long-running / standing widgets](#long-running--standing-widgets). |539| **A comment-driven revise loop** (human pins comments on the widget; an agent edits the file) | **parley + `widget agent`** | `widget push <file.json>` (or `--project-dir <root>`) in one terminal, `widget agent` in another | The human comments on the file-backed parley page (no flow app); `widget agent` turns each comment into a file edit + re-push. See `fused-feedback` → *CLI-native comment feedback*. |540| **The flow UI** (browse **saved dashboards**) — *separate tool, out of scope* | flow | `flow` (or `npx @fusedio/flow` once published) | Human-driven. **Only `widgets/<stem>.json` live-renders** — a `json` UDF shows as source. |541| **A shareable URL** (stakeholder, no local server) | deploy | `fused udf deploy sales-board --project <p>` | A rendered widget URL (preview → promote to release) |542543- **Finding the target (don't `ls` the workspace).** When the human just names a544 widget and/or project ("open the `session_cost` widget of `cc-open`"), resolve545 it with the CLI, not by listing directories:546 - `fused project list` → JSON array of every project (`name`, `path`, `exists`).547 Skip it when the human already named the project.548 - `fused project show <project>` → includes a `"widgets": [...]` array of the549 project's saved-widget **stems** (the `widgets/<stem>.json` you'd pass to550 `open`), plus its references and components. One call confirms the project551 **and** lists its widgets — replacing a `ls ~/.openfused/workspaces/…` + `ls552 widgets/` walk with one structured command. When the human named both the553 project and an obvious widget, skip discovery entirely and go straight to554 `fused widget open <stem> --project <project>`.555- `widget open <target>`: `<target>` is a `.json` path or a saved-widget stem.556 It launches/reuses the app server, opens the browser, and blocks. `--no-open`557 still blocks (prints the URL). This is the canonical "ask the human" path for558 an agent. The block is **time-bounded**: `--timeout` seconds (**default 600 =559 10 min**), after which it prints `{"action":"timeout"}` and exits **3**;560 `--timeout 0` waits forever (see [Long-running / standing widgets](#long-running--standing-widgets)).561 - **`-c/--config TEXT` (or `--config -` for stdin):** pass the widget config562 **inline** instead of a `<target>` — one call instead of `Write` a `.json`563 then `open` it. Mutually exclusive with `<target>`. Best for **one-shot**564 asks (approvals, questions) that settle and exit; the config is **not565 editable** (no durable file), so for an edit-and-refresh loop keep authoring a566 named `.json` and `open` its path. `widget push -c/--config` is the parley567 counterpart (no temp file; `--source PATH` sets the edit anchor).568 - **`--project-dir PATH`** (`.json`/`--config` targets only; on both `widget569 open` **and** `widget push`): pins `fused dev serve`'s `?projectDir=` mode to a570 project directory so UDFs in `scripts/` and the project `.venv` are available.571 **Pass this for almost any real project widget** — one that references a UDF572 elsewhere in `scripts/` or a `{{_core.*}}` ref. Omitting it leaves the surface in573 widget-dir (`?dir=`) mode, which sees **only** the `.py` files sitting next to the574 widget file; a `{{ref}}` to anything else then fails (often a misleading `unknown575 endpoint`/ValueError). Being *inside* the project tree is **not** enough — the mode576 is set by this flag, not by file location577 (see [Pick the right addressing mode](#pick-the-right-addressing-mode)).578 Mutually exclusive with `--project`. **On `push`, it is the entry point to579 feedback mode:** a `.json` path pushed `--project-dir` resolves against the580 project *and* stays file-backed, so the parley comment loop works on a581 `scripts/`-backed widget — the only push form that is both project-addressed and582 editable (a `{project, stem}` push resolves but is not editable). See583 `fused-feedback` → *CLI-native comment feedback*.584- **parley** (`widget push`/`watch`/`parley`/`agent`) is the standing585 agent↔human channel — successive views land on one persistent page and the586 human's events stream back as NDJSON. Use it for iterative refinement. Details587 + flags: `fused-cli` (widget section).588- **The flow UI** (separate tool, out of scope) renders **saved dashboards**589 (`widgets/<stem>.json`) natively, with data resolved through `fused dev serve`. A590 `json` UDF (`scripts/<name>/main.json`) is **not** live-rendered there — it591 appears as source/spec; preview a `json` UDF with `widget open` (above) or deploy it.592- **Deployed** `json` UDFs build a self-contained `widget.html` bundle + a gated593 resolver data route → a stable widget URL. This is the one place a renderer594 bundle (not the app) serves the widget.595596### Long-running / standing widgets597598**Yes — a widget can stay up long-term, but `widget open` is the wrong tool for599it.** An `open` *session* lives only **as long as its command runs**: the block600gives up after `--timeout` seconds (**default 600 = 10 min** → `{"action":"timeout"}`,601exit 3), and on *any* exit (timeout, Ctrl-C, the tab closing) it **de-registers602the widget** — so you cannot "open it and walk away." Pick by what "long-running"603means:604605- **One long-lived ask that just needs more patience — `widget open --timeout 0`**606 (wait forever). The block never expires; it returns only when the human acts (or607 you interrupt). Because it then blocks *indefinitely*, you MUST drive it from608 `run_in_background` and Monitor its **stderr** for the `widget page:` URL — never609 a foreground `--timeout 0` you can't get back from (same rule as610 [Optimistic open + background verify](#optimistic-open--background-verify)).611- **A widget that genuinely persists across a long session — the parley** (the612 real long-running surface). Unlike an `open` session, a pushed parley view613 **persists in the widget-host independently of the short-lived `push` command**:614 `widget push <cfg>` posts a view and **exits immediately**, the view stays on the615 standing `/parley` page, and `widget watch` (its `--timeout` defaults to **0 =616 watch forever**) streams the human's events as NDJSON. Push successive views onto617 the same page over time. This is the surface to reach for when the widget must618 outlive a single blocking call.619 - **Consume the event stream with a `Monitor`, not a plain background task.**620 `watch` streams forever, so a `run_in_background` task only notifies you when it621 *exits* (it never does) — you'd sit blind while the human comments. Run `watch`622 **directly as the `Monitor` command** (`persistent: true`) so each NDJSON line623 wakes you the instant the human acts. Arm it **before** the first `push`. This is624 the same pattern documented in `fused-feedback` → *React live with a Monitor*:625 ```626 Monitor(description: "parley human events", persistent: true,627 command: "fused widget watch --from latest")628 ```629 (The other "Monitor" mentions above are for polling **stderr** for the `open`630 URL — a different job than consuming this event stream.)631632Either way, the **widget-host itself is a detached background process that633outlives the CLI call** — it binds a fixed loopback port (default 4410) and a634later `widget` command *reuses* it — so the host (and any parley view on it) keeps635serving between commands. What's transient is the per-`open` **session**, not the636host.637638### Optimistic open + background verify639640`widget open` **blocks until the human responds**, so you cannot both open a641widget and do anything else from the same foreground call. Two rules follow — and642together they kill the blind-`sleep` anti-pattern.643644**Open optimistically; verify in the background — don't gate the human's view on645the headless check.** `verify` is a data + catalog gate, not a render gate, and646it's fast. Launch `open` first so the human sees the widget the moment the server647is up, and run `verify` *concurrently* to catch resolve/catalog problems — then648react (push a fix, flag an empty result) if it comes back dirty. Serialising649verify → open only adds latency to the human's first paint for a check that650rarely fails once the SQL is written.651652**Poll for readiness — never blind-`sleep`.** Because `open` blocks, start it653with `run_in_background` and watch its output for the `http://…` URL instead of654guessing with `sleep 8; cat log`. A fixed sleep is both too slow (you pay the655worst case every time) a656657…(truncated)