This page is for people changing Lolly's code: how the engine, the shells and the tool packs fit together, where each lives in the repository, and the commitments that constrain every change. If you want to know what Lolly does and who uses it, read How Lolly works first.
Two companions go deeper. engine/README.md is the module-by-module map of the engine, generated from the code. Threat Model & Trust Boundaries reads the same architecture as trust boundaries, and it is the page for any question about what the engine treats as untrusted.
The big picture
Tools are data at the top. The engine in the middle knows nothing of any platform. The shells at the bottom implement one contract, and the catalogs supply the content.
If you can describe it in pure data and Handlebars, it belongs in the engine. If it touches the DOM, the filesystem, the network or any browser or operating-system API, it belongs in the host, the shell's side of the bridge. The engine is open source, and nothing that knows about a particular brand or platform goes into the engine.
Repository layout
Lolly is one repository. Every directory below is a plain directory in it, except brands/suse, a private git submodule holding the SUSE tool pack and catalog, which a public clone does not fetch. Which packs a build reads is a content profile (profiles.json), resolved per process rather than switched globally. Build Guide has the clone command and how a change inside the private pack is committed.
| Directory | What it holds |
|---|---|
engine/ | The platform-agnostic core: loader, runtime, inputs, templates, URL mode, units and the format modules |
shells/ | The web app, the Tauri desktop and mobile apps, the CLI, the TUI and the browser extension |
packages/ | The tool-author SDK (@lolly-tools/core), the shared Node shell pieces and the docs renderer, among others |
community/ | The brand-agnostic tools, plus the shared emoji packs |
brands/ | Brand packs: lolly-demo (Lolly Demo, the design system lolly.tools runs), lolly-start (the blank starter) and the private suse |
services/ | The optional MCP, certificate and Penpot services, and the server code they share |
schemas/ | JSON Schema for tool manifests, asset entries and asset references |
scripts/, tests/, docs/, api/ | Build and catalog scripts, the engine test suite, these docs and the generated serverless bundles |
The engine and shell files you meet first
engine/src/
index.ts public surface: loader, runtime, template, inputs, url-mode
loader.ts fetches and validates tool files
runtime.ts orchestrates one mounted tool, from load to export
template.ts Handlebars hydration and annotateTemplate
inputs.ts manifest to runtime input model
url-mode.ts URL to input state and back
validate.ts JSON Schema validation of manifests
compose.ts nested tool renders (composes)
embed.ts portable lolly.tools embed URLs
bridge/host-v1.ts type re-export of the @lolly-tools/core contract
shells/web/src/
main.ts boot and routing
bridge/ the web implementations of the host APIs
catalog/sync.ts boot-time catalog sync and offline cache
views/ the gallery, tool, projects, free canvas and the other views
pro/ Batch (#/batch)
shells/cli/ bin/lolly.ts (entry), src/run.ts (headless render), src/bridge.ts
shells/tui/ src/main.tsx (the full-screen app), src/bridge.tsThere is no repository-root tools/ or catalog/ directory. packages/node-shell/src/content-roots.ts answers "where does tool <id> live" and "where is the catalog" from profiles.json when asked. A real tools/ and catalog/ pair is written only into a build output, such as dist/, an RPM payload or a container image, because a browser fetches those two paths over HTTP.
Where Lolly runs
Every surface uses the same engine and the same tool files.
- The web app. A progressive web app that works offline once its service worker has cached the tools and assets. It needs no account; state lives in IndexedDB on each device. One responsive layout serves desktop and phone: on a phone the controls become a sheet and the export controls open from a floating pill.
- The desktop and mobile apps. Tauri packages of the same web app, with native file dialogs, filesystem state and full offline use.
- The CLI.
lolly <tool-id> --input=value --output=file.pngloads the same engine in a headless DOM and writes the file. It is URL mode over a different transport, not a separate implementation. See CLI. - The TUI. A full-screen, keyboard-first terminal app that reuses the CLI's bridge, keeps saved sessions on disk (in the desktop app's own folder when the app is installed, otherwise under
~/.lolly) and starts a scoped headless browser only when a format needs one. See TUI.
signed by Lollyvector SVG131 paths~6.1k nodes158 groups1 image142 KBCheck it yourselfGet the signed file
The same tool at phone width needs no second layout: the controls become a sheet at the top and the preview holds the screen.
signed by Lollyvector SVG97 paths~3.3k nodes113 groups99 KBCheck it yourselfGet the signed file
signed by Lollyvector SVG97 paths~3.3k nodes113 groups100 KBCheck it yourselfGet the signed file
The web app also ships Batch (#/batch, shells/web/src/pro/), a spreadsheet-style grid that renders many rows at once across one or many tools; Batch and Multi-edit covers Batch. Settings → Capabilities maps everything the platform declares it can do.
signed by Lollyvector SVG133 paths~15k nodes185 groups1 image195 KBCheck it yourselfGet the signed file
signed by Lollyvector SVG133 paths~15k nodes185 groups1 image195 KBCheck it yourselfGet the signed file
Architectural commitments
These decisions are settled. Changing any of them is a major undertaking, because they shape every other decision in the codebase.
1. Declarative tools, with an imperative escape hatch
A tool is a manifest (tool.json), a template (template.html) and optional hooks.js. The manifest declares the inputs, never the template: inputs are not inferred from Handlebars tokens. Most tools are manifest and template alone, which is what lets non-developers author them.
signed by Lollyvector SVG39 paths~2.6k nodes54 groups2 images43 KBCheck it yourselfGet the signed file
signed by Lollyvector SVG39 paths~2.6k nodes54 groups2 images43 KBCheck it yourselfGet the signed file
Tools that compute values, such as QR encoding or chart shaping, add hooks: named lifecycle functions the runtime calls with the capability bridge injected. Hooks are a portability contract, not a security sandbox, and they run within a time budget. Tools whose hooks are proven free of page globals carry isolate: true and run in a Worker. Tool hooks has the lifecycle and budgets, and the Threat Model says what the in-page executor does and does not contain.
2. Tools and assets are data, not bundled code
The web and Tauri apps fetch the tool and asset catalogs at boot, cache them and work with whatever is there, so a new tool or asset needs no app release. Asset bytes are SHA-256 checksummed, and an asset's id and version drive cache invalidation.
3. The capability bridge is the only API tools see
Tools never touch the DOM outside their template, never call fetch directly and never read the filesystem. They call versioned host.* methods. The contract's canonical definition is packages/core/src/host-v1.ts, the tool-author SDK @lolly-tools/core, so a third party can build against it without depending on the engine; engine/src/bridge/host-v1.ts re-exports its types.
| Required API | What it does |
|---|---|
host.profile | The user's details; pre-fills inputs through bindToProfile |
host.assets | Catalog queries, asset resolution and the host's picker |
host.state | Save and load input slots: IndexedDB on the web, the filesystem in Tauri, memory in the CLI |
host.clipboard | Write text or an image to the clipboard, with platform fallbacks |
host.export | Rasterise or serialise the render target, and the on-device file transform path |
host.log | Leveled logging to the host |
Every other API is optional and additive: tokens, text, color, geom, pdf, compose, media, recorder and many more. A tool's manifest lists under requires the optional APIs its hooks call without checking first, and a host that lacks one refuses to mount the tool; hooks feature-detect the rest. A few, such as recorder, also need a device capability the tool declares. packages/core/src/host-v1/apis.ts lists them all and Host API documents each one. Adding a method is a minor version; removing or changing one is a major version, and v1 keeps working when v2 ships.
4. Asset ids are forever
lolly/logo/primary is a contract. Once published, an id never changes and is never reused. A byte change bumps version in the asset entry, and a replacement sets deprecated: true and, optionally, replacedBy, so existing references always resolve. That is what keeps saved sessions and shared links working for years.
5. URL mode is first-class
Every input is expressible as a URL parameter, so a link is a finished file:
https://lolly.tools/t/qr-code?url=https://example.com&ecl=H signed by Lollyvector SVG55 paths~2.6k nodes38 groups60 KBCheck it yourselfGet the signed file
signed by Lollyvector SVG55 paths~2.6k nodes38 groups61 KBCheck it yourselfGet the signed file
Change a parameter and you have a different file. This whole palette is one seed colour, a harmony and a step count:
signed by Lollyvector SVG10 groups25 KBCheck it yourselfGet the signed file
signed by Lollyvector SVG10 groups25 KBCheck it yourselfGet the signed file
The CLI builds the same URL state from its arguments and runs the same pipeline, so the CLI cannot drift from the app. engine/src/url-mode.ts parses and serialises; a set of reserved names (format, width, dpi, c2pa, z and others) is never passed to a tool, and Reserved URL parameters lists them all. Asset inputs travel by id and resolve through host.assets.get() before the template fills.
6. Storage goes through the bridge
The web app uses IndexedDB, Tauri the filesystem and the CLI memory. Tools see only host.state.save(slot, data) and host.state.load(slot), never localStorage. People can keep several named sessions per tool with no account. Because the bridge is the only seam, that state is also portable: shells/web/src/data-transfer.ts reads it back out into one lolly-backup file that imports on any other install (Data transfer), and Sync sends the same bundle to storage the person chooses.
7. Maturity tags say what a tool is
Every tool declares status: official | community | experimental. The gallery sorts by status, and experimental tools watermark their exports by default: the runtime turns the watermark on unless the caller passes watermark: false, and on-device tools are exempt. Whatever process a team puts around the catalog sits on top of this.
8. Inputs are typed in the manifest
An input declares a type: text, longtext, number, boolean, color, select, asset, date, time, datetime-local, url, blocks, vector, table or file. The host renders a control per type, so tools write no control code, and any input can carry bindToProfile. Three carry the most weight:
asset, withfilterandallowUpload, is the bridge to the asset system.allowUpload: falselimits a slot to library assets, and user uploads share the library'sAssetRefshape.blocksis a repeating field group edited in a side panel, used by Meeting Planner, Wayfinding, Diagrams and others. Clicking a rendered block focuses its row.fileholds the user's own file in memory for on-device utilities such as Strip Hidden Data and Compress, andvectorgroups a fixed set of numbers into one control.
9. Templates are logic-less
Templates are Handlebars, chosen so non-developers can write them: {{x}} escapes, and {{{x}}} is an explicit opt-in. Logic lives in hooks.js, where it is explicit and reviewable. Many templates use {{{ for markup a hook computed, so each hook must escape what it hands over. The helpers are default, upper, lower, eq, markdown, asset, framing and media, plus icsStamp, rfcText, csvCell and arrow for sibling .ics, .vcf and .csv templates. Templates and rendering covers them.
10. Tools compose tools
A tool can embed another tool's render with no tool-to-tool imports; the engine resolves the composition. A manifest can declare composes: [{ id, tool, inputs }] and place the child with {{asset <id>}}, and a portable embed URL (https://lolly.tools/tool/<id>.svg?…) renders the child locally, which is how Design places a live QR code or chart. An SVG child stays vector in an SVG or PDF parent. Children are intermediates, never watermarked or stamped, and a shell that cannot render one leaves the slot empty while the parent still renders. Composition and brand overlays has the details.
What we chose not to do
- No arbitrary JavaScript in templates. Logic lives in
hooks.js. - No mandatory asset server. People add their own files to Assets in the app and hand work on as a session: a share link for inputs and settings, a
.lollyfile when uploads travel too. A shared session can become a template in a tool's chooser. An organisation that wants a governed catalog can use Lolly Work's catalogue, approvals and policies, or manage the brand pack in git and review changes there. - No forced access control. The open app is public by default, and maturity tags and watermarks carry the brand risk. Organisation sign-in, roles and policy come from Lolly Work when a team wants them.
- No central database. User state lives on each device, and optional Sync copies it to storage the person chooses, never to a Lolly database.
- No shared code path between tools and the engine. The engine and the community tools are open source, and a brand pack such as the private
brands/suse/carries its own tools and catalog under its own terms. No tool imports fromengine/, so the split stays clean.
The lifecycle of one render
Someone opens https://lolly.tools/t/qr-code?url=https://example.com&ecl=H:
- Boot. The web app opens IndexedDB, builds the capability bridge and syncs the tool and asset catalogs, or loads them from cache when offline.
- Route. The path selects the tool view for
qr-code, with the query as its initial values. - Load.
loadToolfetchestool.json, validates it against the schema and fetches the template, styles and hooks. - Parse the link.
parseUrlStateturns the parameters into input values. An asset reference such as?logo=lolly/logo/primarybecomes an unresolved{ id }. - Start the runtime.
createRuntime(tool, host, initialValues)builds the input model from profile data, defaults and the link, resolves asset references, loads the hooks and callsonInit. - Render. The shell subscribes to the runtime, draws the controls from the model and writes the filled template into
#tool-canvas. - Interact. Typing calls
runtime.setInput, which applies constraints, callsonInput, fills the template again and redraws. - Export. Download calls
runtime.export(node, format), and the shell'shost.exportproduces the file: a raster, a vector from the DOM walker, a PDF, a document or one of the other formats inschemas/tool.schema.json. Physical units are converted per format, and metadata and Content Credentials are embedded where the format can hold them. Experimental tools get their watermark from the host, not the tool. Templates and rendering and Export a file cover the export step.
signed by Lollyvector SVG77 paths~6.0k nodes87 groups3 images88 KBCheck it yourselfGet the signed file
signed by Lollyvector SVG77 paths~6.0k nodes87 groups3 images88 KBCheck it yourselfGet the signed file
The same lifecycle runs in Tauri, and in the CLI with a headless DOM, where the output goes to a file or stdout.
Licences
Code, including engine/, shells/, services/, schemas/ and the documentation generator, is MPL-2.0, in one public repository, lolly-tools/lolly. Documentation prose is CC BY-SA 4.0. Brand packs carry their own terms in each pack's NOTICE.md: the community tools are MPL-2.0, and the private SUSE pack is proprietary to SUSE. About has the full licensing note.