Skip to main content
The 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.tsdist/index.js, bundled with pkgroll) and optional UI pages (ui/dist/ui/, built with vite).

1. The manifest

Freestyle reads the freestyle 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.
Need a one-liner instead? The transform helper wraps a plain string function into an afterCleanup hook:

3. Talk to the UI (optional)

The plugin exposes a small CRUD API to its settings page with Hono middleware, all under one route prefix. The handler branches on method and persists every change through storage:
The page reads and writes through the bridge:
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:
Then, from your plugin’s directory:
This builds the plugin and creates a <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.
Dictate a curse word and watch it get swapped. When you’re done, remove the dev copy:
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 the plugins setting as [specifier, options]. The profanity filter takes a single option, preserveCase (default true), which mirrors the matched word’s casing onto its replacement:
The word list itself isn’t a static option — it’s managed from the plugin’s UI page (add, edit, delete, reset) and persisted with 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 over fetch and returns a native Response — call res.ok, res.json(), res.text() as usual.
  • invoke(channel, payload) runs a host action: copy, toast, or navigate.
  • serverUrl is the origin the page is served from, if you need the raw value.
Always guard for the bridge first, since the page can be opened outside the host during development:
See the full shape in the SDK reference. Next: the full SDK reference.