profanity-filter plugin in the repo is a complete, small example. It swaps curse words for wholesome stand-ins as you dictate. Let’s walk through it.
A plugin has two halves: plugin code (src/index.ts → dist/index.js, bundled with pkgroll) and optional UI pages (ui/ → dist/ui/, built with vite).
1. The manifest
Freestyle reads thefreestyle field in package.json. main points at the built code, and each page in contributes.pages adds a screen inside the app.
2. The plugin
src/index.ts exports a factory that returns a Plugin. The factory reads user options, keeps its word list in a closure, and returns a setup hook (which loads the list from persistent storage), server middleware (a small CRUD API), and an afterCleanup hook that does the actual swapping.
afterCleanup is the last text transform on the server, so it runs after cleanup and the dictionary. It mutates output.text in place. The word list is persisted with PluginStorage (ctx.storage), so edits made in the UI survive restarts.
3. Talk to the UI (optional)
The plugin exposes a small CRUD API to its settings page with Honomiddleware, all under one route prefix. The handler branches on method and persists every change through storage:
4. Test it with link
You don’t need to publish to npm to try your plugin. The repo ships a helper that builds your plugin and links it straight into Freestyle. Wire it into your plugin’s package.json once:
<slug>-dev copy inside Freestyle’s user-data plugins/ directory, symlinked back to your dist/. The -dev suffix means it can sit alongside a real npm-installed copy of the same plugin without clashing.
1
Restart Freestyle
Open (or restart) the app and go to Settings → Plugins. Your plugin shows up in the installed list with a
[DEV] label.2
Enable it
Toggle it on from there. Linking alone puts the files in place — enabling is what activates its hooks and middleware.
3
Iterate
Because
dist/ is symlinked, changes go live after a rebuild. Run pnpm build, then reload the plugin (or restart the app) to pick up your edits.On Windows, symlinks need Developer Mode. If it isn’t enabled,
link falls back to copying your dist/ — in that case re-run pnpm run link after each build to pick up changes.Shipping it
Once it’s working, publish the package to npm (npm publish) and users can install it by name from Settings → Plugins, or you can add its specifier to the plugins setting directly.
Configure it
Options come from theplugins setting as [specifier, options]. The profanity filter takes a single option, preserveCase (default true), which mirrors the matched word’s casing onto its replacement:
storage.
The API bridge
Your UI page is served by the local server and confined to its own/api/plugins/<slug>/… namespace (plus /api/health) — it can’t touch the filesystem or read settings, keys, or history. Freestyle injects window.freestyle as the privileged surface a page gets.
It does three things:
api(path, init?)calls a server route. Plugin UI is served same-origin with the server, so this is a thin wrapper overfetchand returns a nativeResponse— callres.ok,res.json(),res.text()as usual.invoke(channel, payload)runs a host action:copy,toast, ornavigate.serverUrlis the origin the page is served from, if you need the raw value.