---
name: fused-render-authoring
description: Use when writing or debugging fused-render .html view or .py data file — runPython, params, preview mode, blank views, traceback overlays.
---

# Authoring fused-render views

View = sibling pair: `.html` (UI) + `.py` (data). Explorer renders html in iframe, injects `window.fused` (never `<script src>` runtime), page calls local Python via `fused.runPython`. UI state lives in URL params → refresh-proof, bookmarkable. Plain HTML/CSS/JS. No framework, no build step.

## Build order: html first

When free to start on either side, write the `.html` first. User sees layout + controls within minutes; a `.py` is invisible until wired.

- First pass need NOT be complete, but MUST render standalone: controls → params → `draw()` over stub/placeholder data, no `runPython` yet. Calling a `.py` that doesn't exist = traceback overlay or blank view — opposite of feedback.
- As soon as it renders, tell user the embed URL (`/explorer/embed/<path>`, see Testing) so they can watch it grow.
- Then write `main()`, then swap stub → `fused.runPython`. Wiring discipline (Canonical wiring) holds from the first pass — stub data only changes WHERE values come from, not the param flow.
- Stub-first html doubles as the `_preview=1` placeholder — keep it cheap.

## App markers (entry page `<head>`, first 4 KiB)

```html
<meta name="fused-app" />
<meta name="fused-api-version" content="N" />
```

- `fused-app` = ONLY thing making folder an app. No marker → never in /apps, never registered.
- `fused-api-version`: copy N from `fused_render/app_starter/index.html`. Missing tag = version 0. Migration → `fused-render-api-migration`.
- `fused-app-id` (optional, `content="<kebab name>-<8 hex>"`): app's stable identity, written at app creation (older apps: on first `.fused` export) by `fused_render/app_id.py`, same id across every later export = "update of this app". Never write, edit or copy it by hand.
- Optional `icon.svg` (or `icon.png`, lower priority, clipped to rounded square) beside entry page = sidebar glyph + card mark + favicon → `fused-render-app-icon`.

## Python side: `main()`

One plain fn `main(**params)`. Rules:

- **Annotate params** — arrive as URL strings; annotations coerce (`limit: int`, `bool` accepts "true/1/yes/on"). Unannotated = raw string.
- **Default every param** unless truly required.
- **Return JSON-native only** (dict/list/str/int/float/bool/None). DataFrame → `df.to_dict("records")`; datetime/Decimal/numpy → cast.
- Relative paths resolve beside `.py`.
- **Fresh subprocess per call.** No globals survive. Import cost paid every call (pandas ≈ 1 s). Killed at **60 s** (`DEFAULT_TIMEOUT`, `fused_render/executor.py`), no override. Longer work → `fused-render-jobs`.
- `print()` → browser console as `[python]`.

### App SKILL.md — bot-callable files (SPEC §49)

A bot (OpenBot's `py` action) can run a `.py` beside the page **without the page**, through the same `/api/run` the page uses. It learns what each file does from the app's **`SKILL.md`** at the folder root, beside `index.html` — nothing parses the code. No SKILL.md, or no section for a file → a bot cannot call it. Every app with a `.py` that has a `main` ships one.

```markdown
---
name: expense-tracker
description: Local expense ledger. Add an entry, total spend by category or month.
approve: [add_entry.py]   # optional: own-build bots still ask before these
---
# Expense tracker

Data lives in .fused/data/ledger.json; the page and these files share it.

## summary.py
Totals per category for one month. Reads the ledger; writes nothing.
- Args: `month` (str, "YYYY-MM", default: current month)
- Returns: `{"month": str, "total": float, "by_category": {name: float}}`
- Example: `{"action":"py","app":"expense-tracker","file":"summary.py","args":{"month":"2026-09"}}`

## add_entry.py
Appends one expense to the ledger. Writes .fused/data/ledger.json.
- Args: `amount` (float, required), `category` (str, required), `note` (str, default "")
- Returns: `{"ok": true, "id": str}`
- Example: `{"action":"py","app":"expense-tracker","file":"add_entry.py","args":{"amount":12.5,"category":"food"}}`
```

- **Frontmatter:** `name` (folder slug), `description` (one line a bot reads in its app list: what the app does for it). `approve:` lists files that change or delete something the user cares about; it only adds a pause, never removes one.
- **One `## <file>.py` heading per callable file** — the exact filename; that heading is the only thing a bot parses. Helpers without `main` get no heading.
- **First line under the heading** = what it does + what it changes ("Reads the ledger; writes nothing." / "Writes .fused/data/ledger.json"). A bot calling an app it did not build shows the user this line before running.
- **Args with type, default, required; return shape; one example call** with real values. Keep them exactly in step with `main`'s signature — the runner silently drops an arg `main` does not take and fails a missing required one (`ParamError`), so a stale section means a bot call that quietly does the wrong thing.
- **Edit the section in the same change as the `.py`.** Add, rename or delete a file → add, rename or delete its section.
- The file side stays: **one top-level sync annotated `main(**params)`** (the runner binds only `main`; annotations coerce), defaults on every param, JSON-native return, ≤ 60 s, **secrets never in params** (read them from `.fused/data` or the keychain inside `main`). A resident daemon (`fused-render-background-apps`) is not callable this way — say so in prose.

Run: the page's own `POST /api/run {py, html, params}` → `{ok, result, error, stdout, duration_ms}`.

### Available Python libraries

A `pyproject.toml` is always expected — App Doctor's `pyproject` row fails a folder that lacks one. Without one, the app interpreter falls back to stdlib plus exactly this bundled set (repo `pyproject.toml` `[bundled]` extra minus `botocore`/`google-auth`, plus `pyarrow`/`duckdb`/`httpx` from core `[project]` deps). `dependencies` should list the app's own third-party imports — NOT this bundled set; an app that only imports from it declares an empty `dependencies` list, which keeps it on that zero-install interpreter.

- **Data:** `numpy` `pandas` `pyarrow` `duckdb` `openpyxl` `msgpack`
- **Images:** `pillow`
- **Documents:** `python-pptx` `fpdf2` (import name *fpdf*)
- **Network:** `requests` `httpx`
- **Logs:** `drain3`

Same set the standalone Render App (`fused-render-app`, `LEGACY_DEPS` in `fused_render_app/env.py`) gives a `pyproject.toml`-less app — keep the two in step. `botocore` / `google-auth` are on fused-render's interpreter but NOT in this list: Render App lacks them, so always declare them in a folder `pyproject.toml`.

Anything else needs folder `pyproject.toml` (project root only; add `[tool.uv] package = false`). Facts:

- Dependency list **complete** — bundled set NOT unioned in. Import numpy → declare numpy.
- All-or-nothing per folder; only topmost `pyproject.toml` counts.
- First render installs (loader, auto-retry). Non-PyPI sources / custom indexes → consent prompt. No-wheel deps stop with "install anyway". Commit `uv.lock`.
- Never run `uv sync` by hand — skips readiness marker + consent (`fused_render/projectenv.py`).
- `# /// script` headers IGNORED. Delete; move deps into `pyproject.toml`.
- Version question? Probe live env: `POST /api/run` with throwaway `main()` returning `importlib.metadata.version(...)`. Don't guess.

## App state: `.fused/`

Auto-created at app root. Convention, no helper API — build paths off `os.path.dirname(os.path.abspath(__file__))`:

- `.fused/data/` — state app owns, cannot rebuild. NOT beside index.html, NOT `~/.myapp`, NOT tmp.
- `.fused/cache/` — rebuildable derived bytes. Deleting must cost only time. No auto-sweep — cap yourself.
- `.fused/meta.json` — `{version, app_dir, created_at}`. `app_dir` ≠ actual path → folder moved; distrust stored absolute paths.
- Machine-local: gitignored, excluded from export. Nothing required-on-fresh-machine here.

**Cache aggressively.** Fresh subprocess + 60 s kill → memoize fetches/parses/aggregations to `.fused/cache/`. Key = hash of all inputs **with version segment** (bump on shape change). Write atomic (`os.replace` temp file — calls overlap). Unreadable entry = miss. Cache expensive step, not `main()` wholesale. Bytes (PNG/parquet) → cache file, hand page `fused.rawUrl(path)`.

## HTML side: `window.fused`

| Call | Notes |
|---|---|
| `await fused.runPython(py, params, opts?)` | Runs `main(**params)`; path relative to html. Rejects with Error carrying `.type/.message/.traceback/.stdout`. **Stale-cancel by default**, keyed per pyPath: new call aborts prior in-flight one, superseded promise never settles. `opts.key` regroups; `opts.key: null` = full concurrency; `opts.signal` = own AbortSignal. |
| `fused.params.get/getAll/set/onChange` | Strings only — `set("n", 5)` THROWS, do `String(n)`. `set` = replaceState then `onChange`. `_`-prefixed keys are shell's: `set` throws, `get` undefined (`_file` readable exception). |
| `await fused.readFile(path)` | UTF-8 text. |
| `await fused.stat(path)` | `{path, name, is_dir, size, mtime, writable, remote, templates}` (+ `template_error`). Size-guard big reads; grab `mtime` before edits; check `remote`. |
| `await fused.writeFile(path, text, opts?)` | Atomic. `opts.expectedMtime` → rejects `.type==="conflict"` on stale disk; `opts.create` → rejects `.type==="exists"` (race-free create); readonly → `.type==="readonly"`. Resolves with fresh stat — keep its mtime. |
| `fused.rawUrl(path)` | Sync URL for raw bytes — img/video/embed/download. Also resolves relative sibling assets (pitfall below). |
| `fused.ai.*` | → `fused-render-ai`. |
| `fused.fileIndex.search/query` | Machine-wide file index — use instead of walking fs → `fused-render-index`. Full fused-render only (Render App below). |
| `fused.capture.*` | Native screen/mic/screenshot → `fused-render-capture`. |
| `fused.trackJob(spec)` | Report long work to download manager; never rejects → `fused-render-jobs`. |
| `fused.tasks.*` | List/create/follow up/cancel/watch the app's Claude tasks (headless, returns a handle); `ui()` gives an iframe URL of the shell's Tasks page → `fused-render-tasks`. |
| `fused.daemon.*` | Folder's warm worker / resident daemon → `fused-render-background-apps`. |
| `fused.terminal.open({cwd}?)` / `.run(command, {cwd}?)` | Opens the shell's terminal drawer, optionally `cd`'d into `cwd` and/or running `command`. Rejects (Error) with no shell host (standalone/embed page, hosted export) or on Windows (terminal unsupported). |
| `fused.env` | `"local"` vs `"hosted"` (exported). |
| `fused.autoReload(false)` | Kill reload-on-file-change (in-page editors). Render App: `autoReload(true)` THROWS (no live reload there). |

**Render App** (standalone `fused-render-app`: same bridge, subset runtime). Session is on it when the system prompt says so, the app is a `.fused` bundle or a folder under `~/Fused/local/`, or a call throws `<name> is not supported on Render App`. There: `fused.fileIndex` and `fused.snapshot` do not exist (no stubs — reading them throws that sentence); `fused.autoReload(true)` throws; `fused.capture` is macOS-only, no browser `client` recorder; `runPython` cap is 600 s, not 60; no `fused-render calls` CLI (Verifying below) — read the browser console. Everything else in the table is identical. App that needs the missing members belongs in full fused-render; don't polyfill.

- Uncaught `runPython` rejection → red traceback overlay (good default). Catch for custom UI.
- Filesystem ONLY via these helpers — never fetch `/api/fs/*` yourself (writes rejected, unstable contract).
- Page served at `/render?path=...`, NOT own dir: bare `<script src="./app.js">` 404s **silently** (no overlay, page inert). Use `fused.rawUrl("app.js")`. Blank dead view → check console for 404 first.
- Editing pattern (stat → readFile → writeFile with expectedMtime, handle conflict/readonly): see built-in code editor template.

## Canonical wiring

Params ARE state. Controls write params; `onChange` = single re-render path; `draw()` reads params (never control) → refresh/bookmark reproduces view. Values to `runPython` stay strings — annotations coerce. Worked example: `fused_render/templates/fusedapp/template.html`.

## Preview mode (`_preview=1`)

Shell renders pages nobody opened: /apps cards, listing peeks, hovers — stamped `_preview=1` (+`_nofocus=1`), many boot at once. Ungated boot = N cold starts on home page.

- Read flag AND climb parent frames — inherited, and only an ANCESTOR URL may carry it. Reading `location.search` alone answers `false` for a framed page and boots it. Copy this (the runtime's own `selfOrAncestorHasFlag`, `fused_render/static/runtime.js`):

```js
function isPreview() {
  if (new URLSearchParams(location.search).get("_preview") === "1") return true;
  try {
    let w = window;
    while (w.parent && w.parent !== w) {
      w = w.parent;
      if (new URLSearchParams(w.location.search).get("_preview") === "1") return true;
    }
  } catch (e) { /* cross-origin ancestor: the climb ends here */ }
  return false;
}
```
- Under preview: cheap static placeholder. NO runPython, network, model loads, daemon starts, capture, writeFile, trackJob, timers, websockets, state mutation, dependency installs.
- Forward `?_preview=1&_nofocus=1` to iframes you mount.
- Param sync stops at each embed shell boundary — nested embeds keep params independent (tabs don't share state).
- Preview may SHOW cached artifact, never compute one.
- `_noopen=1` ≠ preview — real interactive surface, just not counted as "open". Ignore in `isPreview()`.
- Runtime covers only `fused.daemon.*` rejection + URL writes; rest is yours to gate.

## Theming

Iframe = blank canvas; shell follows OS/pref light-dark. Quick answer: `data-fused-theme="shell"` on `<html>`, two `:root` token blocks against `data-theme`. Simple apps (forms, dashboards, tools): define colours as `var(--fused-bg, fallback)` etc. so they follow the selected colour preset too. Full rules → `fused-render-theming`.

## Cross-browser (MANDATORY for every view)

Views open in the user's default desktop browser — Chrome/Edge, Firefox, Safari — and the macOS WKWebView popover. Desktop only. Chrome-tested ≠ done.

- **Load `fused-render-cross-browser` BEFORE writing the first `<style>`** — it holds the reset to paste, the Safari prefixes, form-control/scrollbar/font/JS traps with fixes. Not optional, not only-when-broken.
- Features: only MDN **Baseline: Widely available**. Newly-available → `@supports` + fallback. Chrome-only → never.
- No build step, no autoprefixer — prefixes hand-written.
- Before "done": same `/explorer/embed/…` URL in a second engine (`open -a Safari <url>` / `open -a Firefox <url>`).

## Preview templates

Same html, opened FOR target file: read-only `_file` param carries path. Reader `.py` only when Python adds value; text → `readFile`, media → `rawUrl`. Built-in sources: `fused_render/templates/<name>/` (see `xlsx/`; server serves staged copy `~/.fused-render/.core-templates/`). Extension → mode list: `fused_render/templates/registry.json`. User overrides → `fused-render-custom-templates`.

## Testing

Real browser against the server that is ALREADY running. Never assume a port — `1777` is only a bare `fused-render` on main; the desktop app picks a free port, a worktree gets a per-branch one, Render App (fused-render-lite) uses `2777`. Find the origin:

1. `$FUSED_RENDER_ORIGIN` — exported by both servers to every process they spawn (a session opened from the app has it).
2. `~/.fused-render/server.json` (Render App: `~/.fused-render-app/server.json`) — `origin` field, for a terminal session. Probe `<origin>/api/config` before trusting it; a crashed server leaves the file behind.
3. Neither → nothing is running; start one (`fused-render --no-browser --port <free>`; Render App: `open -a RenderApp`, it writes `server.json`).

URLs under that origin:

- `/explorer/embed/<abs path, leading slash dropped, segments URL-encoded>` — chrome-free. **Default for testing.**
- `/explorer/view/<path>` — full shell chrome.
- Templates: open TARGET file's path; or template html directly with `?_file=<abs target>`.

Render App only (no fused-render installed — origin on `2777`, `server.json` under `~/.fused-render-app`): its embed equivalent is `/render?path=<abs html>` — `path` is the entry FILE (`<app dir>/index.html`), never the bare folder; any absolute `.html`, `runtime.js` injected, relative `.py` resolved against it, env picked from the folder's `pyproject.toml`, params after `path` in the URL. `/explorer/*` there is the chat shell, not a view. Same render → interact → refresh loop; verification is the browser console (see below).

Loop: render → interact → URL updates → hard refresh → identical view.

## Verifying: call log

Cannot run page JS from terminal. Full fused-render only — Render App keeps no call log; there, ask the user for the browser console (uncaught errors and unhandled `runPython` rejections land there) and treat a blank page as JS died. After user opens page:

```
fused-render calls --page <abs html> --since 15m   # --failed, --json, --follow
```

Read digest. Zero records + visible placeholder = preview-gated, fine. Zero records + blank page = JS died pre-boot (look for `page error` = window.onerror). `error` = Python raised (traceback + params in record). Way more calls than interactions = onChange/set render loop. High `stale` = normal for slider drags only. Raw store: `~/.fused-render/logs/<app>/*.calls.jsonl`.

## Pitfalls

- `params.set` non-string → throws. `_`-prefixed page param → throws/collides.
- Own `history.replaceState` → drops shell `_` keys, breaks pane.
- Non-JSON return, unannotated numeric param, expecting state across calls.
- Plain `open(...,"w")` cache write; unversioned cache key; irreplaceable bytes in `cache/`.
- Import outside bundled set, no `pyproject.toml`.
- Slider + heavy import, no ~150 ms debounce → subprocess per tick.
- Walking fs for counts/sizes → `fused.fileIndex.query` (`fused-render-index`). Render App has no index: walk in the `.py`, cache the result.
- `fused.ai.text(` in a page meant for HOSTED export → exporter rejects textually, env guard no help. A `.fused` app file allows it (`fused-render-ai`).
- Claiming "done" without `fused-render calls` (Render App: without the console) — blank-JS and failing-Python look identical without log.
