Authoring Tools

A tool is a folder. Drop it in tools/, add a tool.json + template.html, run npm run build:catalog to register it, done. (catalog/tools/index.json is generated from the manifests - never hand-edited; see Publishing.)

Authoring with AI Agents

If you have the lolly.tools repo in front of your agents, you can simply ask them to make tools for you using whatever challenge you think will resolve the design solution.

Sounds hard? not if you have the tokens and any source material. Lolly developers tested 600+ human-created logo lock-up combinations as separate svg files with only paths. They then directed agents to create a tool that could reproduce the source material.

One lunch-break later and the tool became real, and behaved to our satisfaction. Even if you rely mostly on this method, it's good to understand how tools operate.

Start from a design you already have

You don't always start from a blank manifest. If the layout already exists in Figma, Penpot, Illustrator or InDesign, bring it in with Layout Studio's Import a design button and skip straight to a working artboard.

A finished file - a native Figma .fig, a Penpot export, or any SVG (InDesign and Illustrator export it, and nearly every design app can) - is parsed on your device and lands on the free canvas as editable boxes: text stays retypable, shapes stay shapes, images join your library, and type and colours conform to the brand globals. From there it's an ordinary session, so it already behaves like a tool:

Import gets you the visual 90% without writing a line of tool.json. Reach for a hand-authored, fully declarative tool (sidebar inputs, hard-coded constraints, hooks) when you need those knobs - the anatomy below is that path.

Anatomy

tools/your-tool-id/
├── tool.json           # required - declares inputs, outputs, identity
├── template.html       # required - Handlebars-flavoured markup
├── styles.css          # optional - auto-scoped to #tool-canvas
├── hooks.js            # optional - imperative escape hatch
├── thumb.png           # optional - gallery thumbnail (recommended)
├── i18n/               # optional - <lang>.json string overlays (see Localizing a tool)
└── assets/             # optional - tool-local images, fonts, etc.

The manifest (tool.json)

Validated against schemas/tool.schema.json. Required fields:

Optional:

None of those fields stay private to the repo. The gallery's About card is the manifest read back to whoever is deciding whether to open the tool: name, category and status from identity, the export chips and canvas size from render, the version, and a capabilities line whenever the tool declared any.

The About card for the Halftone filter, listing its exports grouped as vector, raster and video chips, its 1000 by 1000 canvas and its version, all read straight from the manifest

The render block

Most of what render declares surfaces in one place the user sees: the export popup. Formats, page size and unit, the Convert paths outlining toggle and the Content Credentials card are all keys below.

The export popup - format and size fields, a Convert paths toggle and a pre-ticked Content Credentials card

render carries width, height, formats (one or more of svg, svg-anim, emf, eps, eps-cmyk, dxf, pdf, pdf-cmyk, cmyk-tiff, tiff, pptx, png, jpg/jpeg, webp, avif, webm, mp4, gif, apng, webp-anim, html, md, txt, json, csv, ics, vcf, ico, zip), plus these optional keys:

Physical units & print. width/height are values in the export's unit (px default, or mm/cm/in/pt), and dpi sets raster resolution for physical units. PDF exports a true page size; the CMYK formats (pdf-cmyk, cmyk-tiff) pair with the convertPaths outlining toggle to produce print-ready, fonts-not-installed output. A select option can also carry width/height/unit to drive the export page size from a dropdown - e.g. wayfinding-signage's Sign size select (A4/A3/A2… in mm) sets the printed page proportions when chosen.

Multi-page PDF. A tool builds a paginated PDF by marking page boxes in its template with data-pdf-page - each flagged element becomes one true PDF page sized to its own CSS box, so a cover, content that flows across pages, and a back page render as real pages rather than one tall image. Pages are drawn as vectors (text outlined to paths) and the document can carry an open-password. The path falls back to the normal single-page renderer when no [data-pdf-page] boxes are present, and it bypasses the crop/bleed print-finishing path (pair it with printMarks: false). See the multi-page-pdf tool for the reference layout (cover + flowing blocks content + back page).

Example looks (examples)

A tool ships one committed thumbnail, but examples lets its gallery tile demonstrate range: an array of example input value-sets, each rendered live on the client (the same off-screen engine path an export takes) as a horizontally-scrollable preview strip - and, when the tool is featured, as the hero row's cross-fade. Each look is memoised, so later visits are instant. Omit it for a tool whose single committed preview says enough.

"examples": [
  { "label": "Launch teal",  "values": { "heading": "Ship it", "background": "#0c322c" } },
  { "label": "Reverse mark", "theme": "dark", "values": { "ink": "mono" } }
]

npm run validate:catalog checks every look: values keys must be declared input ids (a urlKey gets a pointed error naming the right id), catalog asset refs must exist (and any ?theme= suffix must name a real icon theme), blocks-row keys must be declared fields. It also warns when a tool declares looks but no gallery-displayable format (svg/png/jpg/jpeg/webp), and when a strip exceeds 8 looks - each look is a live render, so keep it to a handful of genuinely different ones.

The pre-examples alias featured.variants still renders but is deprecated - author examples.

Input types

Each declaration becomes a real control, built by the shell from the input model - you never write the UI. Six lines of inputs in qr-code's manifest produce this entire sidebar.

The QR tool's sidebar - a URL field, two colour swatches, an error-correction dropdown, a quiet-zone slider and a joined-modules toggle, all generated from the manifest

TypeWhat it producesUI control
textstringtext input
longtextstringtextarea
numbernumberinput or slider
booleanbooleancheckbox
colorstring (hex)color picker, or constrained to a palette asset via palette: "asset/id"
selectstring (one of options[].value); an option may carry width/height/unit to set the export page sizedropdown
assetAssetRef object (id, url, type, etc.)host-provided asset picker
dateISO date stringtext input in the sidebar; native date field in the /pro grid
timeHH:MM stringtime input
datetime-localISO datetime stringflatpickr datetime picker
urlstringtext input
blocksarray of objects (repeating field groups)add/remove/reorder row editor
vectorobject { fieldId: number } (a fixed set of numbers)one row of zoom x/y controls
filea FileRef (the user's own file: name/mime/size/bytes)file picker (on-device utilities)

Four declarations of four different types are four different controls. color-palette declares exactly that and nothing else: a color, a select, a number and a boolean.

Colour Palette's whole sidebar - a swatch trigger, a harmony dropdown, a shades slider and a neutrals switch, one control per declared type

text and longtext differ only in the declaration, and the shell picks the control: a single-line field for one, a sized textarea for the other. prompt-to-image's prompt is a longtext.

The prompt field in Prompt to Image - a tall textarea holding many lines, produced by nothing more than type longtext

The three moment types (date, time, datetime-local) are real input types with real controls, but no tool in the open community set declares one, so there is no screenshot of them here.

blocks - repeating groups

A blocks input is a list of repeating sub-records (e.g. team members, each with a name and city). Declare the per-row fields under fields:

{
  "id": "people",
  "type": "blocks",
  "label": "Team members",
  "fields": [
    { "id": "name", "type": "text",  "label": "Name" },
    { "id": "city", "type": "text",  "label": "City" }
  ]
}

In the template, iterate with {{#each people}}…{{/each}}. The value round-trips to the URL as a JSON array (see docs/url-mode.md); rows larger than ~8 KB fall back to saved-state slots. Blocks are edited in a side panel, and clicking a rendered block on the canvas focuses that block's field. meeting-planner is the reference implementation for the simple (homogeneous) case.

The Slides tool's blocks input - each row is its own card of fields, carrying the row type as its label and an Add slide button below the stack

Advanced blocks (typed / heterogeneous rows). Sub-fields aren't limited to text - a field may be text, color, select, asset, number, or boolean. And the row set can be discriminated by a select sub-field:

color-block is the reference for typed/heterogeneous blocks (addMenu keyed on a kind select, showFor, multilineFor, and the full sub-field type set).

Drop files to add rows. A blocks input may declare dropToAdd: { field, accept } - dropping one or more files onto the blocks list appends one row per file, uploading each into the named asset sub-field (the row's other fields start at their defaults). accept is a MIME filter (default image/*). logo-wall is the reference: drop many logos → one block each.

Paste a Markdown document (mdPaste). A blocks input may set mdPaste: true to add a Paste Markdown button to the blocks toolbar: it reads the clipboard, splits the Markdown into one block per heading (heading line → the block's heading field, the section beneath → its body field, kept as Markdown for a {{markdown}} render), and appends the blocks - so a whole document lands as editable, page-flowing blocks. Used by the paged/document tools.

Import rows from a spreadsheet (importData). A blocks input may declare importData: { formats?, mode?, columns? } to offer an Import data button that fills the whole list from a CSV or JSON file - the ingest counterpart to CSV/JSON export. The engine (parseDataRows) maps columns onto the block's sub-fields: an explicit columns map ({ fieldId: "Column Name" }) wins, otherwise each column header/key is matched case-insensitively to a field's id then its label. formats limits the accepted types (default both); mode is replace (default) or append. JSON may be an array of objects, an array of arrays (positional, in field order), or { "data": [ … ] }. The imported rows are ordinary blocks - they serialise to the URL and save like any hand-entered data. chart-creator is the reference: import a two-column Label,Value sheet to chart it.

Reference pickers (optionsFrom). A sub-field can be a dropdown whose choices are the rows of another blocks input - so a row references another row by a friendly name instead of a hand-typed id. Declare optionsFrom on the field:

{ "id": "parent", "label": "Reports to",
  "optionsFrom": { "input": "nodes", "value": "nodeId", "label": "label",
                   "excludeSelf": true, "excludeDescendants": true, "emptyLabel": "- Top level -" } }

The value stored is the target row's derived id - slug(value field), else slug(label), else an ordinal, de-duplicated - i.e. exactly the id a hook resolves with (your hook should slug both a row's id and the back-reference, so the two agree). A stored value matching no current row is shown as a selected "(unknown)" option rather than vanishing, so a stale reference is visible. Options: value/label/prefix (the source sub-fields + ordinal prefix), sources: [{input,value,label}] to merge several inputs (e.g. cards and layers, de-duped by value), freeText: true for a combobox (datalist) that also accepts a typed-in value (e.g. a new kanban column), excludeSelf, excludeDescendants (needs nesting, below), and emptyLabel.

Tree blocks (nesting). A blocks input can be edited as a tree: the sidebar renders the flat array as an indented outline (pre-order) and the header drag drops a card above / below (sibling) or inside (child) another, updating its parent reference - the whole subtree travels with it. The data stays a flat reference-by-id array, so it serialises and renders exactly as before (the renderer still walks the parent pointers). Declare nesting on the input:

{ "id": "nodes", "type": "blocks", "nesting": {
    "parentField": "parent", "keyField": "nodeId", "labelField": "label",
    "activeWhen": { "diagramType": ["org", "mindmap"] } } }

activeWhen gates tree mode by top-level input values (an array value matches by membership); omit it to always nest. diagram-builder is the reference for both optionsFrom and nesting (org / mind map nest; process / kanban / layercake stay flat and reference by picker).

Editor canvas: connectors, grid & fixed size (canvas.connect / grid / fixedCanvas)

A blocks input carrying a canvas object is the free-form WYSIWYG artboard behind render.layout: "editor" (see The render block): its *Field keys map each row's geometry (xField/yField/wField/hField/rotationField, plus fill/text/image sub-fields) so the shell can mount its select / drag / resize / rotate overlay while the data stays a flat, URL-expressible array. The shell mounts the whole editor rail for you - add, arrange, undo and the primary export actions - so the manifest declares geometry fields and nothing else.

The free-canvas editor rail the shell mounts for an editor layout - add, arrange, undo and export, none of it declared by the manifest

Three of the canvas keys turn a plain box canvas into a diagram editor:

``json "connect": { "input": "connectors", "fromField": "from", "toField": "to", "styleField": "style", "arrowField": "arrow", "headField": "head", "colorField": "color", "dashField": "dash", "widthField": "width", "layerClass": "oc-connectors", "defaultStyle": "elbow", "defaultArrow": "end", "defaultHead": "triangle", "defaultColor": "#94a3b8", "defaultWidth": 2.5 } ``

org-chart is the reference implementation: an editor-layout box canvas with grid, fixedCanvas: true, and a connect writing to a connectors blocks input whose rows its hook turns into one artboard <svg> of arrows.

vector - a group of numbers as one control

Use vector when a few related numbers belong together - zoom + pan, an x/y offset, padding, margins. Instead of separate number inputs (one column each in /pro bulk mode), a vector is one input, one control, one column: a row of compact number fields where each label can be dragged to scrub the value (Figma-style) or typed into. Declare the numeric sub-fields under fields:

{
  "id": "imageFraming",
  "type": "vector",
  "label": "Zoom & Position",
  "fields": [
    { "id": "zoom", "label": "Zoom", "min": 100, "max": 400, "step": 1, "default": 100 },
    { "id": "x",    "label": "X",    "min": 0,   "max": 100, "step": 1, "default": 50  },
    { "id": "y",    "label": "Y",    "min": 0,   "max": 100, "step": 1, "default": 50  }
  ]
}

A vector control in Mesh Gradient - one labelled row of compact number fields you can drag to scrub or type into

The value is an object keyed by field id, so the template reads each part with dot access: {{imageFraming.zoom}}, {{imageFraming.x}}, {{imageFraming.y}}. Each field clamps to its own min/max and falls back to its default.

In URL mode (and /pro CSV) each field is its own flat param/column, namespaced "<inputId>.<fieldId>" - e.g. ?imageFraming.zoom=200&imageFraming.x=30&imageFraming.y=70, or CSV columns imageFraming.zoom, imageFraming.x, imageFraming.y. There is no urlKey on a vector. filter-duotone and quotes (both imageFraming) are the reference implementations.

imageFraming is a canonical input (see below) - reuse that id and field set verbatim for any zoom/pan-an-image control rather than inventing a synonym.

asset - library or device upload

An asset input opens the host's asset picker and stores the chosen AssetRef - uniform whether it came from the catalog or the user's device:

{
  "id": "logo",
  "type": "asset",
  "label": "Logo",
  "assetType": "image",    // vector | raster | image | video | lottie | any - constrains the picker
  "allowUpload": true       // also let the user add an image from their device
}

assetType constrains what the picker offers: raster (bitmaps only), vector (SVG only - for inline-recolourable logos), image (any still image - raster _or_ vector, the right choice for a generic picture slot), video, lottie, or any (everything, including non-image assets). Prefer image over raster for photo/illustration slots so users can also pick or upload SVGs.

The Image row in the Halftone filter - a thumbnail slot and a Choose asset button that opens the host's picker, with nothing about pickers in the manifest

When allowUpload is true, the picker offers the user's personal image library alongside the catalog. Users add images from their device; the host downscales each to 3840px on the longest edge, re-encodes it (WebP, with EXIF/GPS metadata stripped), and stores it locally (IndexedDB on web and Tauri). The library is capped (currently 50 images), reusable across tools, and managed in Profile → Storage → My images. SVG uploads are sanitised on ingest (script/handler stripping) and pass through without rasterising.

These images are device-local: their AssetRef.source is "user" and their user/… id is meaningful only on the device that holds the bytes, so they are omitted from shareable URLs (see docs/url-mode.md). Tools treat user and library assets identically - no tool code is involved in the upload.

Use any tool as an image (paste a Lolly link). Every asset input also accepts a Lolly tool link pasted into the picker's search box - a share link copied from another tool (…/#/tool/qr-code?url=…) or an embed URL (…/tool/qr-code.svg?…). The host renders that tool (via host.compose) and drops the result into the slot; the user can pick the render format and size before committing. This is the end-user counterpart to authored composes (below) - no manifest declaration needed, and it works in every tool's image inputs by default. The picker offers SVG and bitmap render formats for any image slot (SVG is the default - it stays crisp and inlines as true vector in SVG/PDF export, and rasterises cleanly for PNG); a vector-typed slot is restricted to SVG. The chosen asset's identity is the canonical embed URL, so it persists in saved sessions and shareable links and re-renders on load - exactly like a library id. (The picker offers this whenever the shell can compose; the compose capability gates only authored composes, not this end-user path.)

file - the user's own file (on-device utilities)

A file input takes a file the user picks into memory and hands its raw bytes to the tool. It's the input shape for content-transform utilities - the "boring file jobs you'd otherwise hand to a stranger's website": strip EXIF, crop, compress, convert. Unlike asset (which is for brand imagery and goes through the catalog/upload library), a file is the user's own content that's processed and handed straight back, never stored or uploaded.

With layout: "canvas" a single file input stops being a sidebar row and becomes the working area itself - the drop zone strip-data opens with.

Strip Hidden Data's canvas - a drag-and-drop file zone with a Choose a file button and the note that nothing is uploaded

{
  "id": "photo",
  "type": "file",
  "label": "Photo",
  "accept": ["image/jpeg", "image/png", ".jpg", ".png"],
  "maxSize": 52428800
}

The value is a FileRef: { __file: true, name, mime, size, bytes, url }. The bytes are a Uint8Array the hook reads directly (no host. call - the bytes ride in the value by design, because the portable host. surface has no file-read API). A file value is never serialised into a URL (binary has no shareable form) and never persisted - it lives only in memory on the device, which is the whole privacy point. In CLI transport a file param is a path the runner loads: --photo=./pic.jpg.

Producing output: the exportFile hook + privacy: "on-device"

A content-transform utility doesn't rasterise the canvas - it produces a transformed file. Declare the exportFile hook and mark the tool as an on-device utility:

{
  "status": "official",
  "privacy": "on-device",
  "render": { "width": 760, "height": 620, "formats": ["jpg"], "export": false, "actions": [] },
  "hooks": { "onInput": true, "exportFile": true }
}
function exportFile({ model }) {
  const inputs = Object.fromEntries(model.map(i => [i.id, i.value]));
  const f = inputs.photo;                       // the FileRef
  const cleaned = stripMetadata(f.bytes);       // your transform (pure bytes → bytes)
  return { bytes: cleaned, mime: f.mime, filename: f.name.replace(/(\.\w+)?$/, '-clean$1') };
}

In the template, a <button data-export-file>Download…</button> triggers the hook; the shell wraps the bytes in a Blob and delivers them via host.export.file (download on web, --output on the CLI). Use onInput/onInit to return extras the template displays (e.g. what metadata was found). strip-data is the reference implementation.

bindToProfile

Any input can declare bindToProfile: "firstname" (or email, headshot, etc). When the tool mounts, it pre-fills from the user's profile. They can override per-session.

Canonical inputs (reuse shared ids)

/pro (the web shell's batch mode) is a spreadsheet grid that renders many rows at once across one or many tools - CSV/TSV round-trip and spreadsheet paste in, a .zip of per-row outputs out, with collapsible export columns and saved batch sessions. Because it lays every selected tool's inputs out as a grid, the id/constraint choices you make below directly shape that grid.

/pro batch mode lays every selected tool's inputs out as a grid. It keys each column by input id - so two tools that call the same concept by the same id collapse into one column, and if they also agree on type + constraints (number min/max/step, select options, color palette), that column becomes bulk-writable: the user types one value and it fills every row. Diverge on the id (or the constraints) and you get a separate, cell-by-cell column instead. So picking a shared id is a real UX decision, not a style preference.

To make this the default path, the blessed ids and their constraints live in schemas/canonical-inputs.json. When your tool needs one of these concepts, copy the id (and constraints) verbatim:

ConceptCanonical idType
Headlineheadingtext
Sub-headlinesubheadingtext
Body copybodylongtext
Call to actionctatext
Ink / foreground colourcolorcolor
Background colourbackgroundcolor
Primary image · portrait · backdropimage · headshot · bgImageasset
Background image dimmingbgOpacitynumber (0–1, step 0.01)
Zoom + pan an imageimageFramingvector { zoom, x, y } (zoom optional)

Conventions: per-element typography numbers are <element>FontSize / <element>FontWeight (weight 100900 step 100), e.g. headingFontSize, bodyFontWeight.

Labels are advisory - show whatever label fits your tool; the /pro header just uses the first non-empty one, and bulk-write only cares about id + type + constraints. Adding a genuinely new shared input? Add it to schemas/canonical-inputs.json first, then adopt it - npm run validate:catalog emits a warning (never an error) when a tool uses a canonical id with a divergent type or constraints, so drift stays visible.

The template (template.html)

Handlebars-flavoured. Logic-less by design.

<div class="my-tool">
  {{#if heading}}
    <h1>{{heading}}</h1>
  {{else}}
    <p>(enter a heading)</p>
  {{/if}}

  {{#if logo}}
    <img src="{{asset logo}}" alt="" width="{{asset logo "width"}}">
  {{/if}}
</div>

wordmark is about as small as a template gets: one string, one face, one weight. Everything below came from the link's params flowing into {{ }} slots, with no code in between.

The Wordmark canvas rendering the word Handlebars at weight 800, the whole output of a template whose only moving part is one text value

Custom helpers. The engine registers these in engine/src/template.ts (the source of truth - this table should list exactly what it registers, no more, no fewer):

HelperWhat it does
{{default x "fallback"}}x unless it's null/undefined, then the fallback.
{{upper s}} / {{lower s}}Upper/lower-case a string.
{{eq a b}}Strict equality - use inside a condition, e.g. {{#if (eq kind "note")}}.
{{markdown body}}Render a small Markdown subset (headings, bold/italic, links, lists, code) to safe HTML. Used for blocks bodies and pasted Markdown.
{{arrow text}}A leading > < ^ v becomes → ← ↑ ↓ (for directional labels).
{{asset ref}}The resolved URL of an asset input. Use in src/href.
{{asset ref "width"}}A specific field of the asset (width, height, …).
{{media ref}}Emits the right element for any asset kind - <img>, <video>, or a Lottie marker - from one call. Options hash: class, style, loop, autoplay, muted, controls, fit (contain/cover), key.

The data-format helpers {{icsStamp}}, {{rfcText}}, and {{csvCell}} are for the sibling text templates - see Data formats below.

Styles (styles.css)

Scoped automatically. Write top-level selectors targeting your own classes. Don't write global rules (body, html); they'll be scoped to #tool-canvas and probably won't do what you want.

Data formats (json / csv / ics / vcf)

Some tools export data alongside the rendered image - a calendar invite, a contact card, the underlying numbers. These come from the input model, not the pixels, so they work in every shell (including the CLI) and don't need a browser.

Example template.ics (see tools/meeting-planner/):

BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
DTSTART:{{icsStamp meetingTime}}
SUMMARY:{{rfcText title}}
LOCATION:{{rfcText city}}
END:VEVENT
END:VCALENDAR

Reference wirings: meeting-planner→ICS, email-signature→vCard, chart-creator→CSV. Raster (png/jpg/webp/avif/gif), svg, pdf, the print/CMYK formats (pdf-cmyk, cmyk-tiff), video (webm/mp4), zip, and ico come from the browser (web shell) or the Tauri-bundled CLI - the node CLI handles only text/data formats. The CMYK formats pair with the convertPaths outlining toggle (see The render block) for fonts-not-installed print fidelity; pdf-cmyk ships on more tools than cmyk-tiff does (a subset) - e.g. qr-code offers both, while wayfinding-signage and event-name-badge ship pdf-cmyk.

Hooks (hooks.js)

Optional. Required only if you need computed values, async data, or anything the template can't express.

A layout no logic-less template could reach is the sign that you need one. The D3 tool parses its pasted table, runs the layout, and hands the template a finished shape list as extras; the template itself just prints it.

A treemap from the D3 tool - nested rectangles sized and placed by a hook, with the template only printing the shapes it was handed

// Top-level functions are picked up by name. Declare any you need.
function onInit({ model, host }) {
  // Run once. Return a patch object to seed derived values.
  return { computedThing: derive(model) };
}

function onInput({ id, value, model, host }) {
  // Run after every input change. Return a patch (or nothing).
  return { computedThing: derive(model) };
}

function beforeExport({ node, format, opts, host }) {
  // Modify the node, or call host APIs before raster/serialize.
}

function afterExport({ node, format, blob, host }) {
  // Fires after the export blob is produced. Cleanup, telemetry, chaining.
}

function exportFile({ model }) {
  // The transform path - for on-device utilities with a `file` input. Read the
  // picked file's bytes and return the transformed file: { bytes, mime, filename }.
  // Bypasses the DOM render/export pipeline entirely. See the `file` input above.
}

function onFrame({ frame, model, host }) {
  // Live camera (v1.4). Runs once per webcam frame so the render reacts to motion.
  // `frame` = { width, height, data (RGBA Uint8ClampedArray), t }. Read pixels
  // synchronously; return a patch like onInput. See "Motion-reactive tools" below.
  return { svgContent: traceFrame(frame, model) };
}

Declared hooks must be flagged in the manifest's hooks object ({ "onInit": true, ... }) - a manifest with no hooks object never loads hooks.js at all, and the flags are what validation and shell affordances (e.g. the transform-download wiring for exportFile) read.

Shared helper regions (community/_shared/)

hooks.js must stay self-contained (no import/require - tools are data), so helpers that several tools need (the filter overlay block, canRaster, loadImage, esc, clamp, safeColor) are maintained once in community/_shared/*.js and copied byte-for-byte into each consumer between marker comments:

// === lolly:shared clamp - generated from community/_shared/math.js; edit there and run npm run sync:shared ===
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
// === /lolly:shared clamp ===

Never hand-edit inside the markers: edit the canonical file, run npm run sync:shared, and npm run validate:catalog fails on any drift. See community/_shared/README.md.

Motion-reactive tools (onFrame)

Declare an onFrame hook and your tool can react to a live camera - the shell shows a "Go live" toggle wherever a camera is available (host.media), and the runtime drives onFrame once per frame. This is pure progressive enhancement: onFrame is never called where there's no camera, so the tool still works as an ordinary still-image tool. Do not add camera to capabilities - that would require a camera and hide the tool where there isn't one.

A frame carries raw pixels (frame.data, RGBA), so the usual move is to wrap them in a canvas the still pipeline already understands and reuse it:

function onFrame({ frame, model }) {
  const c = document.createElement('canvas');
  c.width = frame.width; c.height = frame.height;
  c.getContext('2d').putImageData(new ImageData(frame.data, frame.width, frame.height), 0, 0);
  return { svgContent: build(c, inputsFrom(model)) }; // same builder as onInit/onInput
}

Keep it cheap - onFrame isn't time-boxed, but the runtime drops a frame if the previous one is still rendering, so an expensive per-frame render just lowers the frame rate. The four filter-* tools are the reference (halftone/scanline/posterise/duotone); pixel-tracers wrap the frame as above, while the SVG-filter duotone hands the frame back as a data-URL image instead.

Recording tools (render.capture + onLevel)

Set render.capture and the tool grows a record button that captures the user's mic (and optionally camera) to a file - the audio/video counterpart to the file transform path. Three modes:

Recording prompts for a device permission, so - unlike the live-camera onFrame path - it is a gated capability: declare "microphone" for audio, "camera" for video, and both for av. The tool is then unavailable on shells that can't record (the headless CLI provides no host.recorder). The recorded bytes reach the user through the transform path (host.export.file, never watermarked) or become a template asset a compositing tool wraps.

"render": { "width": 1080, "height": 1080, "formats": ["png", "svg"], "capture": "audio", "actions": ["download", "save"] },
"capabilities": ["microphone"],
"hooks": { "onInit": true, "onLevel": true }

The onLevel hook - a live VU meter / sound check. Declare it and the runtime drives it once per audio-level sample (from the pre-record meter, and again during the take), exactly like onFrame drives a camera frame - drop-overlap, not time-boxed. It returns a patch like onInput:

function onLevel({ level, model, host }) {
  // `level` is an AudioLevel (below). Return a patch the template renders.
  return { barPct: Math.round(Math.min(1, level.rms / 0.5) * 100), tooHot: level.clipping };
}

An AudioLevel is { rms, peak, dbfs, clipping, t } - rms (0–1 loudness, the value a VU bar tracks), peak (0–1 instantaneous), dbfs (peak in dB; 0 = clip, −∞ = silence), and clipping (true while peak sits at the "too hot" threshold, ~0.99). Engine v1.19 adds four optional background-noise cues (feature-detect - undefined on shells that don't compute spectral levels): noiseFloor (dBFS floor in the quiet gaps), snr (dB signal-to-noise; ≲15 dB = a noisy room), hum (0–1 share of energy in the mains bands - electrical hum / ground loop), and hiss (0–1 spectral flatness - broadband fan/HVAC hiss). The noise cues are trustworthy only from the raw meter (the sound-check runs the mic with noise-suppression/AGC off); a recording session runs them on for a clean file, so its floor reads artificially low.

voice-recorder (capture: "audio" + onLevel coaching) and top-tail-recorder (capture: "av") are the reference tools; the host.recorder bridge (meter / record) is documented in Host API.

What you can call:

What to stay away from:

Newer optional host APIs (feature-detect)

The bridge grows by addition: new host.* APIs arrive in minor engine versions, are never removed, and are optional - an older shell simply doesn't have them yet. Feature-detect and degrade. If your tool genuinely can't work without one, raise the manifest's engineVersion floor instead (e.g. ">=1.60") - the engine refuses to load a tool whose range excludes the running version, which fails clearer than a missing method would. Recent additions:

``js function onInput({ values, host }) { if (!host.geom) return {}; // older shell: leave the path alone const cut = host.geom.difference([values.shape, values.hole]); if (!cut.ok) return { pathError: cut.code }; // never silently wrong const outline = host.geom.stroke(cut.d, 4, { cap: 'round', join: 'round' }); return { shapePath: cut.d, outlinePath: outline.ok ? outline.d : '' }; } ``

Network access (host.net)

Tools are offline-first: by default a tool cannot reach the network at all. A live-data tool (weather, status, an RSS ticker, an iCal feed) opts in with two manifest declarations - the capability, and an explicit allowlist of what it may fetch:

"capabilities": ["network"],
"network": {
  "allowlist": [
    "https://api.example.com/*",
    "https://example.com/status.json"
  ]
}

The allowlist rules:

In hooks, host.net.fetch is an ordinary fetch - just gated:

async function onInit({ model, host }) {
  try {
    const res = await host.net.fetch('https://api.example.com/v1/status');
    if (!res.ok) return { statusText: 'unavailable' };
    return { statusText: (await res.json()).status };
  } catch {
    return { statusText: 'offline' }; // the tool must still mount without a network
  }
}

Keep fetches inside the hook time budget (onInit 5s, onInput 2s), return template-ready extras, and always render something sensible when the fetch fails - a network tool that renders blank offline is a bug, not a constraint.

Composition (composes)

A tool can embed another tool's rendered output as an image instead of re-implementing it. Declare it in the manifest and reference it in the template like any asset - no hook code, no copy-paste.

// tool.json
"capabilities": ["compose"],
"composes": [
  { "id": "badgeQr", "tool": "qr-code", "format": "svg",
    "inputs": { "url": "{{url}}", "color": "#0c322c", "join": true } }
]
{{!-- template.html - guard it: composition can fail gracefully --}}
{{#if badgeQr}}<img src="{{asset badgeQr}}" alt="">{{/if}}

Composition depth and baking

Nesting is capped at 3 levels - a tool composing a tool composing a tool. A deeper chain fails the same way a cycle does: gracefully, with an empty slot. When a design genuinely needs to go deeper, bake the inner render: tick Freeze as a static image in the picker's render card. A baked image is a frozen copy - self-contained bytes that consume no nesting depth and never live-re-render - so it won't update when the source tool changes. Its slot shows a "❄ baked from …" row with a Re-bake button (and an Edit path into the source tool's inputs) that re-renders on demand, so a stale copy is one click from fresh.

Brand logo (auto-switching)

The catalog ships the SUSE logo as 8 variants under suse/logo/ - {hor|vert}-{neg|pos}-{green|white|black} (hor/vert = wide vs stacked; neg = for dark backgrounds, pos = for light; green is the brand mark, white/black are the high-contrast mono pair). A tool shouldn't hard-code one - it should pick the variant that fits the current background and space, and use the actual SVG image (this is distinct from brand-lockup, which renders the wordmark from the SUSE font, outlined via HarfBuzz host.text).

The pattern: a hook chooses the id, resolves it with host.assets.get(), and hands the template a ready <image>/<img>:

// hooks.js - WCAG luminance decides neg/pos; orientation + ink come from inputs.
function logoId(inputs) {
  const dark   = relLuminance(inputs.background) < 0.5;   // dark bg → neg
  const orient = inputs.orientation === 'vertical' ? 'vert' : 'hor';
  const ink    = inputs.ink === 'mono' ? (dark ? 'white' : 'black') : 'green';
  return `suse/logo/${orient}-${dark ? 'neg' : 'pos'}-${ink}`;
}
async function onInit({ model }) {
  const inputs = Object.fromEntries(model.map(i => [i.id, i.value]));
  return { logo: await host.assets.get(logoId(inputs)) }; // → extras.logo (an AssetRef)
}
<!-- template.html - the actual SVG asset, not a font lockup -->
{{#if logo}}<image href="{{asset logo}}" .../>{{/if}}   <!-- inside an <svg> → true vector export -->
{{!-- or, in an HTML canvas: --}}
{{#if logo}}<img src="{{asset logo}}" alt="Logo">{{/if}}

Putting the <image> inside an <svg> lets the export inline it (data-URI) and emit true vector SVG; an <img> in an HTML canvas exports raster/PDF only. tools/tool-logo/ is the reference implementation (background colour, orientation, brand/mono, transparent-bg export). Reusing this in another org: keep the structure and swap the suse/logo/... id prefix for your own logo namespace (same variant matrix).

Brand overlays (extends)

A brand pack that only needs to tweak a community tool - a different template, a handful of re-worded translations - shouldn't carry a whole fork that silently drifts from its base. Instead, declare the brand's tool dir an overlay:

// brands/<brand>/tools/<id>/tool.json - same id as the community tool
{
  "id": "color-palette",
  "extends": "community",
  ...
}

and keep only the files that differ in the overlay dir. When scripts/use-profile.ts builds the tools/ view, that tool's view dir becomes the per-file union of the base (community/<id>/) and the overlay (brands/<brand>/tools/<id>/):

Fail-closed: a declared overlay whose base is missing (community/<id>/tool.json doesn't exist), an extends value other than "community" (the only base pack in v1), or an extends declared on a community tool itself fails the profile build loudly - even in postinstall --auto - and is also rejected by npm run validate:catalog. You never get a silent partial tool. The composed result is validated like any other tool, since the validator runs against the tools/ view.

Publishing

There are two ways a tool ships - pick per context:

  1. Place your folder under tools/.
  2. Run npm run build:catalog - this regenerates catalog/tools/index.json from

the manifests (don't hand-edit the index; it's generated) and refreshes asset checksums.

  1. Run npm run validate:catalog to confirm the catalog is consistent.
  2. Build & deploy the catalog. The shell picks it up on next boot.

For development:

npm run dev:web
# open localhost - your tool appears in the gallery

Localizing a tool

A tool's user-facing strings live in the manifest (English by default). To translate it, add an i18n/<lang>.json sidecar - a sparse, flat, dotted-path overlay of just the strings a translator touched:

// tools/your-tool-id/i18n/de.json
{
  "name": "…",
  "description": "…",
  "inputs.headline.label": "…",
  "inputs.headline.help": "…",
  "inputs.size.options.a4": "…"
}

The same sidebar, opened with ?lang=de: labels, help text and select options all come from the sidecar, and the tool code is untouched.

The QR tool's sidebar in German - every label, hint and dropdown option translated by the i18n sidecar

When a tool loads with a language set (the reserved lang URL/CLI param, or the user's profile language), the engine best-effort fetches the matching i18n/<lang>.json and merges it onto the manifest before any shell or the input model sees it - one overlay point, every shell (web, CLI, TUI) benefits. Anything missing - no sidecar, an absent key, a malformed file - falls back to the manifest's English, so a translation gap never breaks a tool load. Keys cover name, description, a11yLabel, and per-input label / help / placeholder / section / suffix / options.<value> (block and vector sub-fields as inputs.<id>.fields.<fieldId>.…). validate:catalog checks the keys, so a typo is caught at build time rather than silently ignored.

Example tools