HTML artifacts — authoring a main.html that talks to Fused
An HTML artifact is an agent-authored, standalone HTML document —
scripts/<name>/main.html — that the host (Fused's widget-host, or the Flow app)
serves full-bleed in an iframe and wires to a window.fused runtime. You write one
self-contained page; the host injects window.fused, and your page calls UDFs, runs
SQL, and reads/writes params through it. It is backed by the same UDFs and SQL the
rest of the project uses.
It is the third shape a scripts/<name>/ folder can take, alongside a Python UDF
(main.py) and a JSON UDF (main.json): where those are functions the host runs,
an HTML artifact is a document the host serves and frames — a small, self-contained
app you author once and the human interacts with directly.
When to author one (vs a JSON-UI widget)
Reach for an HTML artifact when the JSON-UI widget catalog (fused-widgets) can't
express the interface — a bespoke layout, a custom visualization, a multi-step form, a
mini-app with its own interaction logic. Reach for a JSON-UI widget (the default)
when a dashboard of standard charts / tables / inputs is enough: it's less code, renders
on more surfaces (including deployed URLs), and is self-verifiable headlessly.
HTML artifacts are completely independent from JSON-UI widgets. Different folder
(scripts/<name>/main.html, not widgets/<stem>.json), different runtime
(window.fused, not the widget renderer). Don't carry a widget assumption over, and
vice versa. Say HTML artifact, never widget, for this surface.
What you author: one self-contained document
Author a single scripts/<name>/main.html. That's the whole deliverable.
- Do NOT wire the runtime yourself. The host injects
window.fused into the page
before your code runs — you never add a <script> tag for it, never import a bridge,
never configure a URL or token. Just call window.fused.*.
- Prefer self-contained. Inline your CSS and JS (or an inlined framework build). The
page is framed and may be sandboxed on some surfaces, so external CDN scripts, fonts,
and remote fetches are not guaranteed to load. Everything the page needs to render
should be in the file. All data comes through
window.fused, not fetch.
- Write plain HTML/CSS/JS (or any framework you inline). There is no required
structure beyond a normal HTML document.
An optional sibling scripts/<name>/spec.md is docs-only (purpose, the UDFs/SQL it
binds) — same spec↔file pairing a UDF has. It carries no runtime role; the Flow app
shows it in the artifact's Main ⇄ Spec toggle.
The window.fused API
window.fused is the only thing your page needs. The portable core below works
identically on both hosts (Fused widget open and the Flow app). Every method is
async-safe and never throws — failures come back in the result, not as an exception.
| Method |
Returns |
What it does |
callUdf(name, overrides?, opts?) |
Promise<{ data, error? }> |
Run a named UDF once and get its decoded output. |
runSql(sql, params?, opts?) |
Promise<{ rows, columns, error? }> |
Run ad-hoc SQL host-side over the project's UDFs. |
getParam(name) |
unknown |
Read the current value of a param. |
setParam(name, value) |
void |
Set a param (notifies every subscriber). |
clearParam(name) |
void |
Clear a param (notifies subscribers). |
onParam(name, cb) |
() => void |
Subscribe to one param; cb(value) on every change. Returns an unsubscribe fn. |
fused.ready |
Promise<void> |
Resolves once param state is available — await it before reading params on load (see below). |
<script>
async function main() {
await window.fused.ready; // params are ready
// Run a UDF, render its output
const { data, error } = await window.fused.callUdf("sales", { region: "emea" });
if (error) { showError(error); return; }
renderChart(data);
// Run ad-hoc SQL over the same UDFs (see the data grammar below)
const res = await window.fused.runSql(
"select month, sum(revenue) as revenue from {{sales?region=$region}} group by month order by month",
{ region: "emea" },
);
if (res.error) { showError(res.error); return; }
renderTable(res.columns, res.rows);
}
main();
</script>
callUdf(name, overrides?, opts?)
name is the UDF's project slug (kebab-case, e.g. "sales", "my-udf") — the
same name a {{ref}} reads it under.
overrides become the UDF's keyword args. Values are coerced to strings
(query params always travel as strings): a string passes through, numbers/booleans are
stringified, objects/arrays are JSON-serialized, null/undefined become "". If the
UDF needs structured input, JSON-encode it and decode inside the UDF.
opts.format selects the decode: "json" (default parsed value), "html", or
"text". opts.signal is an AbortSignal to cancel the call.
- Returns
{ data, error? } — data is null when error is set. Always check
error.
runSql(sql, params?, opts?)
sql is DuckDB SQL using the same {{ref}} / $param grammar JSON-UI widgets
use — see fused-widgets for the full grammar. In short:
{{sales}} — the whole result of the sales UDF; {{sales?region=$region}} — pass
a kwarg bound to the $region param; {{sales?region=emea&limit=50}} — bare
literals.
$name is an inline text substitution for the value in params — works anywhere,
including inside strings.
- Cross-project
{{_core.<project>.<udf>}} refs resolve the same way they do for
widgets (task-management, run-management, …).
params supplies the $param values the statement references. opts.signal
cancels.
- Returns
{ rows, columns, error? } — flat rows/columns. Check error.
Params: reactive state that outlives a reload
getParam / setParam / clearParam / onParam are a small reactive store the host
owns, not your page — so param state survives a document reload. Use params to hold
selection/filter state and to drive re-rendering:
<script>
window.fused.onParam("region", (region) => refresh(region)); // re-render on change
document.querySelector("#emea").onclick = () => window.fused.setParam("region", "emea");
</script>
Because the store is host-owned, getParam before the first snapshot returns nothing —
await window.fused.ready before reading params on load. (On the Fused host ready
resolves immediately; on Flow it resolves on the first param snapshot. Awaiting it is
safe and portable on both.)
On the Fused host, param changes also drive automatic URL query-sync and
parley/session reporting — you get that for free by using setParam, no extra code.
Events (emit / on) — Flow-only, experimental
window.fused.emit(name, payload?) / window.fused.on(name, cb) are a fire-and-forget,
two-way event channel with the host. Available on Flow only; on the Fused host they are
absent. Even on Flow, nothing consumes them yet in v0. Do not build critical behavior
on events — treat them as an experimental hook. For anything load-bearing, use params
or UDF/SQL calls. If you do use them, feature-detect: window.fused.emit?.(…).
Load-order & robustness rules
await window.fused.ready first, before reading any param, on every entry path.
- Guard
window.fused may be undefined. It only exists when the document is rendered
by a host (framed). Opened as a bare file it won't appear — the host logs to the console
and your fused.* call fails at the call site. Don't crash the whole page on load;
degrade gracefully (if (!window.fused) { … }).
- Never assume a call succeeded — branch on
.error for callUdf/runSql, render a
visible error state.
- All data flows through
window.fused. The page makes no direct network calls of its
own; there is no API base URL or token for you to hold.
How the artifact gets rendered in front of a human
Same document, two hosts — you author once:
- Fused CLI —
fused widget open scripts/<name>/main.html serves the document,
injects window.fused, opens the browser, and blocks until the human is done. The
document is served fresh on every load, so your next edit is live on reload.
- Flow app — the artifact appears in the project Explorer as an
html leaf and
renders as a full-bleed product surface (with a Main ⇄ Spec toggle). Live on
save; no command to run.
Host differences (author-relevant)
|
Fused (widget open) |
Flow app |
Portable core (callUdf/runSql/params/ready) |
✅ |
✅ |
emit / on events |
✗ (absent) |
⚠ present, experimental, unconsumed |
fused.ready timing |
resolves immediately |
resolves on first param snapshot |
| URL query-sync + parley reporting from params |
✅ automatic |
✗ (v0) |
| Render surface |
browser via widget open |
in-app product surface (Explorer leaf) |
Author to the portable core (callUdf/runSql/getParam/setParam/clearParam/
onParam + await fused.ready) and the artifact runs unchanged on both.
Authoring checklist
- Decide the split: which UDF(s) compute the data (
scripts/<name>/main.py or
main.json), and the HTML artifact that presents them. Confirm a JSON-UI widget
(fused-widgets) genuinely can't do the job before choosing HTML.
- Write the backing UDF(s) and their
spec.md; verify they run (see fused-execute /
fused-verify).
- Author a single self-contained
scripts/<name>/main.html: inline CSS/JS, no
external CDNs, no bridge wiring. Optionally add scripts/<name>/spec.md.
- Use only
window.fused.*; await window.fused.ready before reading params; check
.error on every callUdf/runSql; guard window.fused being absent.
- Stick to the portable core so it runs on both hosts; feature-detect
emit/on if used.
- Render it:
fused widget open scripts/<name>/main.html (Fused CLI) or open it in the
Flow app Explorer. Iterate — edits are live on reload/save.
See also
fused-widgets — the JSON-UI widget alternative, and the authoritative {{ref}} /
$param data grammar runSql shares.
fused-projects — the scripts/ folder-per-entrypoint layout and project lifecycle.
fused-execute / fused-verify — writing and validating the UDFs the artifact calls.
fused-cli — the fused widget open command surface.
1---2name: fused-html-artifacts3description: Authoring standalone HTML artifacts — `scripts/<name>/main.html` documents that Fused and Flow serve full-bleed and wire to a `window.fused` runtime (callUdf / runSql / params). Use when the output of a project is a bespoke interactive HTML page — a custom chart, a form, a report, a small app — that calls UDFs and runs SQL directly, rather than a JSON-UI widget. Covers the `window.fused` API, the `{{ref}}`/`$param` data grammar, load-order gotchas, host differences, and how the artifact gets rendered in front of a human.4---56# HTML artifacts — authoring a `main.html` that talks to Fused78An **HTML artifact** is an agent-authored, standalone HTML document —9`scripts/<name>/main.html` — that the host (Fused's `widget-host`, or the Flow app)10serves full-bleed in an iframe and wires to a `window.fused` runtime. You write one11self-contained page; the host injects `window.fused`, and your page calls UDFs, runs12SQL, and reads/writes params through it. It is backed by the **same UDFs and SQL** the13rest of the project uses.1415It is the **third shape** a `scripts/<name>/` folder can take, alongside a Python UDF16(`main.py`) and a JSON UDF (`main.json`): where those are **functions the host runs**,17an HTML artifact is a **document the host serves and frames** — a small, self-contained18app you author once and the human interacts with directly.1920## When to author one (vs a JSON-UI widget)2122Reach for an HTML artifact when the JSON-UI widget catalog (`fused-widgets`) can't23express the interface — a bespoke layout, a custom visualization, a multi-step form, a24mini-app with its own interaction logic. Reach for a **JSON-UI widget** (the default)25when a dashboard of standard charts / tables / inputs is enough: it's less code, renders26on more surfaces (including deployed URLs), and is self-verifiable headlessly.2728> **HTML artifacts are completely independent from JSON-UI widgets.** Different folder29> (`scripts/<name>/main.html`, not `widgets/<stem>.json`), different runtime30> (`window.fused`, not the widget renderer). Don't carry a widget assumption over, and31> vice versa. Say **HTML artifact**, never **widget**, for this surface.3233## What you author: one self-contained document3435Author a **single `scripts/<name>/main.html`**. That's the whole deliverable.3637- **Do NOT wire the runtime yourself.** The host injects `window.fused` into the page38 before your code runs — you never add a `<script>` tag for it, never import a bridge,39 never configure a URL or token. Just call `window.fused.*`.40- **Prefer self-contained.** Inline your CSS and JS (or an inlined framework build). The41 page is framed and may be sandboxed on some surfaces, so external CDN scripts, fonts,42 and remote fetches are not guaranteed to load. Everything the page needs to render43 should be in the file. All data comes through `window.fused`, not `fetch`.44- **Write plain HTML/CSS/JS** (or any framework you inline). There is no required45 structure beyond a normal HTML document.4647An optional sibling **`scripts/<name>/spec.md`** is docs-only (purpose, the UDFs/SQL it48binds) — same spec↔file pairing a UDF has. It carries no runtime role; the Flow app49shows it in the artifact's **Main ⇄ Spec** toggle.5051## The `window.fused` API5253`window.fused` is the only thing your page needs. The **portable core** below works54**identically on both hosts** (Fused `widget open` and the Flow app). Every method is55**async-safe and never throws** — failures come back in the result, not as an exception.5657| Method | Returns | What it does |58|---|---|---|59| `callUdf(name, overrides?, opts?)` | `Promise<{ data, error? }>` | Run a named UDF once and get its decoded output. |60| `runSql(sql, params?, opts?)` | `Promise<{ rows, columns, error? }>` | Run ad-hoc SQL host-side over the project's UDFs. |61| `getParam(name)` | `unknown` | Read the current value of a param. |62| `setParam(name, value)` | `void` | Set a param (notifies every subscriber). |63| `clearParam(name)` | `void` | Clear a param (notifies subscribers). |64| `onParam(name, cb)` | `() => void` | Subscribe to one param; `cb(value)` on every change. Returns an unsubscribe fn. |65| `fused.ready` | `Promise<void>` | Resolves once param state is available — **`await` it before reading params on load** (see below). |6667```html68<script>69 async function main() {70 await window.fused.ready; // params are ready7172 // Run a UDF, render its output73 const { data, error } = await window.fused.callUdf("sales", { region: "emea" });74 if (error) { showError(error); return; }75 renderChart(data);7677 // Run ad-hoc SQL over the same UDFs (see the data grammar below)78 const res = await window.fused.runSql(79 "select month, sum(revenue) as revenue from {{sales?region=$region}} group by month order by month",80 { region: "emea" },81 );82 if (res.error) { showError(res.error); return; }83 renderTable(res.columns, res.rows);84 }85 main();86</script>87```8889### `callUdf(name, overrides?, opts?)`9091- **`name`** is the UDF's **project slug** (kebab-case, e.g. `"sales"`, `"my-udf"`) — the92 same name a `{{ref}}` reads it under.93- **`overrides`** become the UDF's keyword args. **Values are coerced to strings**94 (query params always travel as strings): a string passes through, numbers/booleans are95 stringified, objects/arrays are JSON-serialized, `null`/`undefined` become `""`. If the96 UDF needs structured input, JSON-encode it and decode inside the UDF.97- **`opts.format`** selects the decode: `"json"` (default parsed value), `"html"`, or98 `"text"`. **`opts.signal`** is an `AbortSignal` to cancel the call.99- Returns `{ data, error? }` — `data` is `null` when `error` is set. **Always check100 `error`.**101102### `runSql(sql, params?, opts?)`103104- **`sql`** is DuckDB SQL using the **same `{{ref}}` / `$param` grammar** JSON-UI widgets105 use — see `fused-widgets` for the full grammar. In short:106 - `{{sales}}` — the whole result of the `sales` UDF; `{{sales?region=$region}}` — pass107 a kwarg bound to the `$region` param; `{{sales?region=emea&limit=50}}` — bare108 literals.109 - `$name` is an **inline text substitution** for the value in `params` — works anywhere,110 including inside strings.111 - Cross-project `{{_core.<project>.<udf>}}` refs resolve the same way they do for112 widgets (task-management, run-management, …).113- **`params`** supplies the `$param` values the statement references. **`opts.signal`**114 cancels.115- Returns `{ rows, columns, error? }` — flat rows/columns. **Check `error`.**116117### Params: reactive state that outlives a reload118119`getParam` / `setParam` / `clearParam` / `onParam` are a small reactive store the **host120owns**, not your page — so param state **survives a document reload**. Use params to hold121selection/filter state and to drive re-rendering:122123```html124<script>125 window.fused.onParam("region", (region) => refresh(region)); // re-render on change126 document.querySelector("#emea").onclick = () => window.fused.setParam("region", "emea");127</script>128```129130Because the store is host-owned, `getParam` before the first snapshot returns nothing —131**`await window.fused.ready`** before reading params on load. (On the Fused host `ready`132resolves immediately; on Flow it resolves on the first param snapshot. Awaiting it is133safe and portable on both.)134135On the **Fused** host, param changes also drive automatic URL query-sync and136parley/session reporting — you get that for free by using `setParam`, no extra code.137138### Events (`emit` / `on`) — Flow-only, experimental139140`window.fused.emit(name, payload?)` / `window.fused.on(name, cb)` are a fire-and-forget,141two-way event channel with the host. **Available on Flow only; on the Fused host they are142absent.** Even on Flow, nothing consumes them yet in v0. **Do not build critical behavior143on events** — treat them as an experimental hook. For anything load-bearing, use params144or UDF/SQL calls. If you do use them, feature-detect: `window.fused.emit?.(…)`.145146## Load-order & robustness rules147148- **`await window.fused.ready` first**, before reading any param, on every entry path.149- **Guard `window.fused` may be undefined.** It only exists when the document is rendered150 by a host (framed). Opened as a bare file it won't appear — the host logs to the console151 and your `fused.*` call fails at the call site. Don't crash the whole page on load;152 degrade gracefully (`if (!window.fused) { … }`).153- **Never assume a call succeeded** — branch on `.error` for `callUdf`/`runSql`, render a154 visible error state.155- **All data flows through `window.fused`.** The page makes no direct network calls of its156 own; there is no API base URL or token for you to hold.157158## How the artifact gets rendered in front of a human159160Same document, two hosts — you author once:161162- **Fused CLI** — `fused widget open scripts/<name>/main.html` serves the document,163 injects `window.fused`, opens the browser, and blocks until the human is done. The164 document is served fresh on every load, so your next edit is live on reload.165- **Flow app** — the artifact appears in the project **Explorer** as an `html` leaf and166 renders as a full-bleed **product surface** (with a **Main ⇄ Spec** toggle). Live on167 save; no command to run.168169## Host differences (author-relevant)170171| | Fused (`widget open`) | Flow app |172|---|---|---|173| Portable core (`callUdf`/`runSql`/params/`ready`) | ✅ | ✅ |174| `emit` / `on` events | ✗ (absent) | ⚠ present, experimental, unconsumed |175| `fused.ready` timing | resolves immediately | resolves on first param snapshot |176| URL query-sync + parley reporting from params | ✅ automatic | ✗ (v0) |177| Render surface | browser via `widget open` | in-app product surface (Explorer leaf) |178179**Author to the portable core** (`callUdf`/`runSql`/`getParam`/`setParam`/`clearParam`/180`onParam` + `await fused.ready`) and the artifact runs unchanged on both.181182## Authoring checklist1831841. Decide the split: which UDF(s) compute the data (`scripts/<name>/main.py` or185 `main.json`), and the HTML artifact that presents them. Confirm a JSON-UI widget186 (`fused-widgets`) genuinely can't do the job before choosing HTML.1872. Write the backing UDF(s) and their `spec.md`; verify they run (see `fused-execute` /188 `fused-verify`).1893. Author a **single self-contained `scripts/<name>/main.html`**: inline CSS/JS, no190 external CDNs, no bridge wiring. Optionally add `scripts/<name>/spec.md`.1914. Use only `window.fused.*`; `await window.fused.ready` before reading params; check192 `.error` on every `callUdf`/`runSql`; guard `window.fused` being absent.1935. Stick to the portable core so it runs on both hosts; feature-detect `emit`/`on` if used.1946. Render it: `fused widget open scripts/<name>/main.html` (Fused CLI) or open it in the195 Flow app Explorer. Iterate — edits are live on reload/save.196197## See also198199- `fused-widgets` — the JSON-UI widget alternative, and the authoritative **`{{ref}}` /200 `$param` data grammar** `runSql` shares.201- `fused-projects` — the `scripts/` folder-per-entrypoint layout and project lifecycle.202- `fused-execute` / `fused-verify` — writing and validating the UDFs the artifact calls.203- `fused-cli` — the `fused widget open` command surface.