Build for Ficus
JavaScript in, pixels out.
Everything you need to build your own visualizers and tiles: the frame API, the sandbox rules, the manifest, and the road to the marketplace. The whole thing fits on this page.
viz.on('frame', (f) => {
const ctx = f.ctx;
if (!ctx) return;
const { width: w, height: h } = f.size;
ctx.clearRect(0, 0, w, h);
const bins = viz.bins(24);
const bw = w / bins.length;
for (let i = 0; i < bins.length; i++) {
ctx.fillStyle = f.theme.accent;
ctx.fillRect(i * bw + 1, h - bins[i] * h,
bw - 2, bins[i] * h);
}
});
Anatomy
How bundles work
Everything you can build for Ficus is a bundle: a folder holding a manifest.json and exactly one payload file. Which payload file it is decides what kind of bundle you've made.
Visualizer
JavaScript that draws every frame from live audio data. Runs in a locked-down sandbox — audio in, pixels out, nothing else. Full-power: canvas 2D, WebGL, or DOM.
Tile
A dashboard cell that shows data from an API you choose. Carries no code at all — one JSON file names the source and how to render it. That's why installing one is safe.
MilkDrop preset
A preset for the built-in MilkDrop engine (Butterchurn-compatible). Data, not code — validated presets publish without review.
Visualizers you can build and run locally on your own machine, live. Tiles and presets are installed from the marketplace. Both roads end at Publishing.
Quick start
Your first visualizer
In Ficus, cycle the visualizer with V (or open the gallery) and pick the Scripted style, then hit + New visualizer — or create the folder yourself:
%APPDATA%\com.secondmonitor.hub\visualizers\my-first-viz\
├── manifest.json
└── main.js
Edits hot-reload within about two seconds, from the built-in editor (the ✎ on the tile) or any editor you like. manifest.json is five lines:
{
"id": "my-first-viz",
"name": "My First Viz",
"version": "0.1.0",
"api": 1,
"permissions": []
}
id must equal the folder name (lowercase letters, digits, hyphens). permissions must stay empty for a visualizer — see the sandbox for why that's the deal. And main.js is one callback:
// Pulse — gradient bars with a kick flash.
const N = 48;
viz.on('frame', (f) => {
const ctx = f.ctx;
if (!ctx) return; // took a WebGL context instead? bail
const { width: w, height: h } = f.size;
if (w <= 0 || h <= 0) return;
ctx.clearRect(0, 0, w, h);
const bins = viz.bins(N); // 64 host bins, resampled to N
const bw = w / N;
for (let i = 0; i < N; i++) {
const v = bins[i];
const grad = ctx.createLinearGradient(0, h - v * h, 0, h);
grad.addColorStop(0, f.theme.accent);
grad.addColorStop(1, f.theme.accent2);
ctx.fillStyle = grad;
ctx.fillRect(i * bw + 1, h - v * h, bw - 2, v * h);
}
if (f.onset.kick > 0.05) { // transient envelope, 0..1
ctx.globalAlpha = f.onset.kick * 0.5;
ctx.strokeStyle = f.theme.accent;
ctx.lineWidth = 2 + f.onset.kick * 8;
ctx.beginPath();
ctx.arc(w / 2, h / 2,
Math.min(w, h) * (0.18 + f.bands.bass * 0.25),
0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
}
});
Save, look at your second monitor, and it's moving. Runtime errors show up as an overlay on the tile with a line number — a bad frame never kills the script.
requestAnimationFrame. Timers started at the top level can survive a hot reload and haunt the next version of your code.Reference
The viz API
Inside the sandbox, the global viz is your whole world:
| Member | What it does |
|---|---|
| viz.on('frame', cb) | Registers your draw callback. It runs every animation frame with the payload below. |
| viz.canvas | The <canvas>, auto-resized by the host. Call getContext('webgl2') yourself if you don't want the default 2D context — after that, f.ctx is null. |
| viz.root | A DOM root instead of a canvas, for bundles that declare "surface": "dom" — build your scene from elements and CSS transforms. |
| viz.bins(n) | Resamples the host's 64 spectrum bins to n (nearest-neighbour, 1–4096). Use this instead of rolling your own — it's the formula every built-in style uses, so your output matches theirs. |
| viz.settings.get(key) viz.settings.set(key, value) | Small key–value store, persisted per visualizer. Good for a user-tweakable speed or palette choice. |
The frame payload
Every field your callback receives. Sensitivity and smoothing are already applied host-side — don't re-smooth.
| Field | Type | Meaning |
|---|---|---|
| ctx | CanvasRenderingContext2D | null | 2D context — null if you took a WebGL context first. |
| spectrum | Float32Array(64) | Log-spaced 30 Hz–16 kHz magnitudes, 0..1. |
| waveform | Uint8Array(1024) | Raw time-domain samples; 128 = silence. |
| waveformL / waveformR | Uint8Array(1024)? | Per-channel time domain. Only present when your manifest declares "stereo": true and live stereo has arrived — always fall back: f.waveformL || f.waveform. |
| bands | { bass, mid, treble } | Musical-thirds means, 0..1. |
| onset | { kick, snare, hat } | Transient envelopes that decay over ~150 ms, 0..1. The heartbeat of anything that flashes. |
| level | number | Overall loudness, 0..1. |
| dt | number | Seconds since the previous frame, capped at 0.25. Scale all motion by it. |
| size | { width, height } | Canvas size in device pixels. Can be 0×0 mid-layout — guard for it. |
| theme | { accent, accent2 } | The user's current accent colors, hex strings. Use them and your visualizer belongs in every theme. |
| track | { title, artist } | null | Now-playing metadata; null when nothing is playing. |
| playback | { playing, position, duration } | null | Live playback state in seconds. position is already interpolated for you. |
| sync | grid | null | Spotify beat/bar/section grid (needs "sync": true in the manifest): { track_id, progress_ms, fetched_at_ms, playing, beats[], bars[], sections[] }, each beat/bar { start, duration, confidence }. Honestly optional — often null; fall back to f.onset. |
API v1 is frozen: future fields are added, never changed or removed. What your bundle uses today works forever.
DOM visualizers
Declare "surface": "dom" in the manifest and you get viz.root instead of a canvas — build elements once at the top level, then only mutate styles in the frame callback:
const dot = document.createElement('div');
dot.style.cssText =
'position:absolute;left:50%;top:50%;width:140px;height:140px;' +
'margin:-70px 0 0 -70px;border-radius:50%;transition:none';
viz.root.appendChild(dot);
viz.on('frame', (f) => {
dot.style.background = f.theme.accent;
dot.style.boxShadow = '0 0 80px ' + f.theme.accent;
dot.style.transform = 'scale(' + (0.5 + f.bands.bass * 1.3) + ')';
});
f.theme actually changes.Trust
The sandbox
Your code runs inside a sandboxed iframe with an opaque origin: no cookies, no storage, no app bridge. Its content security policy is default-src 'none' — so from inside a visualizer:
- No
fetch, no XHR, no WebSockets. - No external images, scripts, stylesheets, or fonts.
- No filesystem, no app APIs — the only channel in or out is the frame protocol above.
This isn't a limitation to work around; it's the promise that makes the marketplace possible. Because a visualizer cannot touch the network or the machine, a user can install one from a stranger and the worst it can do is draw badly. Audio in, pixels out.
Tiles keep the same promise a different way: they ship no code at all, and their one declared API host is enforced by the app — see Data tiles.
Reference
Manifest reference
Every bundle's manifest.json, all fields. Only the first five are required.
| Field | Rules |
|---|---|
| id | Required. 1–64 chars of [a-z0-9-]; must equal the folder name. First publisher owns the id forever. |
| name | Required. The display name on the tile and in the store. |
| version | Required, max 32 chars. Use dotted numbers like 1.0.0 — stick to digits and dots, no hyphens. Every published version is immutable. |
| api | Required. Literally 1 — the only published API version. |
| permissions | Required array, max 16. Must be [] for visualizers and presets. Tiles may declare net:<host> (bare hostname) and secret:<key>. |
| author | Optional display attribution. |
| surface | Optional, visualizers: "canvas" (default) or "dom". Anything else is rejected, not coerced. |
| stereo | Optional, visualizers: true to receive f.waveformL/R. Costs per-frame IPC, so only declare it if you read them. |
| sync | Optional, visualizers: true to receive f.sync (the Spotify beat grid). |
| config | Optional, max 8: { key, label, type: "text" | "number" }. Renders a settings input per entry; values reach a tile as {{config.key}}. |
| secrets | Optional, tiles, max 8: { key, label, kind: "password" | "text", help? }. The app collects and stores the credential and injects it into your requests — your bundle never sees the value. Every entry needs a matching secret:<key> permission, and vice versa. |
Store listing fields
Optional metadata the marketplace reads at submission:
| Field | Rules |
|---|---|
| summary | One line for the card, ≤ 100 chars. |
| description | The full listing, ≤ 4000 chars. |
| category | Visualizers: spectrum, wave, scene, or engine. Tiles: media, system, weather, productivity, ambient, or integrations. Presets: milkdrop. Anything else is rejected at submission. |
| tags | Max 8, each 1–24 chars of [a-z0-9-]. |
| icon | 1–2 characters — an emoji works. |
| changelog | What changed this version, ≤ 1000 chars. |
| minAppVersion | Dotted numeric floor, e.g. "0.9.10" if you read f.sync. |
Declarative
Data tiles
A tile puts data from an API onto the dashboard — a stock price, a feed, a score. It carries no code: view.json declares where the data comes from and which native view renders it, and the app does the fetching and drawing. Here's the official Stocks tile, complete:
// view.json
{
"source": {
"kind": "http",
"url": "https://query1.finance.yahoo.com/v8/finance/chart/{{config.symbol}}?interval=1d&range=2d",
"intervalMs": 60000
},
"select": "chart.result.0.meta",
"view": {
"type": "stat",
"value": "{{data.regularMarketPrice}}",
"label": "{{data.symbol}}",
"delta": "prev {{data.chartPreviousClose}}"
}
}
// manifest.json — the host it calls, declared up front
{
"id": "tile-stocks",
"name": "Stocks",
"version": "1.0.0",
"api": 1,
"permissions": ["net:query1.finance.yahoo.com"],
"config": [
{ "key": "symbol", "label": "Ticker symbol (e.g. AAPL)", "type": "text" }
]
}
The three parts
- source —
{ "kind": "http", "url", "headers"?, "intervalMs" }. The URL must behttps://and its host must match a declarednet:permission.intervalMsis clamped to 15 seconds–24 hours, so an installed tile can never hammer anyone's API. - select — a dot-path into the JSON response (
chart.result.0.meta; literal integers index arrays). Omit it to use the whole response. Thelistview expects it to land on an array. - view — one of five native renderers:
stat(value/label/delta),list(a row template over an array, with optionalopenUrl),rows(label/value pairs),text(body/attribution),badge(value/label).
Templating
{{path}} is substitution only — no expressions, no conditionals, no arithmetic. That deliberate dumbness is what keeps an installed tile auditable at a glance. Scopes:
- In
source.urlandsource.headers:{{config.*}}and{{secret.*}}— secrets are injected app-side into the outgoing request only. - In
view:{{data.*}}(the selected response),{{item.*}}(the current element, insidelistrows), and{{config.*}}.{{secret.*}}is forbidden anywhere inview— a credential can never reach the screen.
[object Object] — if your tile shows a blank, your select path stopped one level too early.There's no local sideloading for tiles yet — the road to running your tile is publishing it, which is also what keeps every installed tile a reviewed one.
Marketplace
Publishing
The marketplace is curated: humans approve visualizers and tiles before anyone can install them, and the app cryptographically verifies everything it downloads. Here's the whole road:
- Make an account. Register with your email on the marketplace server (
POST /auth/register, verify,POST /auth/loginfor your token) and choose a handle — published work carries real attribution, so a handle is required before you can submit. - Submit.
POST /submissionswith your session token and a JSON body:kind("visualizer"|"tile"|"preset"),manifest(the rawmanifest.jsontext), andcode(yourmain.jsorview.jsontext) orpreset_json. Optionallypreview: a base64 PNG or JPEG thumbnail, up to 256 KiB. - Pass the static gates. Instant, automatic checks: manifest and view schema, permission grammar, a 256 KB code cap, and a hard rejection of
eval(andnew Function— as literal substrings, so keep them out of your comments too. - Review. Presets auto-approve after validation. Visualizers and tiles wait in a human review queue (an AI report advises the reviewer; it never approves anything). Version updates are reviewed as a diff against your previously approved code.
- Ship. On approval the server packages your bundle, hashes it, and adds it to a signed index. Every Ficus install verifies the index signature and your bundle's SHA-256 before trusting either — and shows users your exact permission list before install.
To update, bump version and submit again — every published id@version is immutable, so a resubmission at the same version is rejected. Your id is yours: nobody else can ever publish under it.
Field notes
Sharp edges
Honest notes from building the sixty-plus official bundles. Each of these has bitten someone.
- Guard the degenerate frames.
f.ctxisnullafter you take a WebGL context, andf.sizecan be 0×0 mid-layout. The first two lines of every official bundle are those two guards. - Don't re-smooth the spectrum. The host already applies sensitivity and smoothing before your callback. Layering your own EMA on top makes your visualizer feel laggy compared to every built-in.
- Scale motion by
f.dt. It's the seconds since the last frame, capped at 0.25 — use it and your visualizer moves at the same speed at 30 and 120 FPS. - Versions: digits and dots only.
1.0.0, never1.0.0-beta— hyphenated versions break the update pipeline. eval(fails even in a comment. The submission gate rejects the literal substringseval(andnew Functionwherever they appear in your file.- Secrets and permissions come in pairs. Every
secretsentry needs a matchingsecret:<key>permission and everysecret:permission needs a matching entry — the manifest is rejected otherwise, because the install dialog must be able to show users exactly what they're approving. surfacetypos are rejected, not defaulted. By design: a typo should fail loudly at validation, not render a blank canvas you can't diagnose.- Stereo isn't guaranteed. Even with
"stereo": true,f.waveformL/Rappear only once live stereo frames arrive. Always writef.waveformL || f.waveform. - Theme colors are an input, not a suggestion. Bundles that hardcode their palette look broken the moment a user picks a different accent. Read
f.themeevery frame — it can change while you're running.
Something here doesn't match what the app does? That's a bug in the docs — tell us.