> ## Documentation Index
> Fetch the complete documentation index at: https://docs.freestylevoice.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Your first plugin

> Build a plugin by walking through the profanity filter example.

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.ts` → `dist/index.js`, bundled with `pkgroll`) and **optional UI pages** (`ui/` → `dist/ui/`, built with `vite`).

```
profanity-filter/
├── package.json
├── src/
│   ├── index.ts          # the plugin (default export)
│   └── replacements.ts   # the word list + matching logic
└── ui/                   # the settings page (React)
    └── src/App.tsx
```

## 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.

```json theme={null}
{
  "name": "@freestyle-voice/profanity-filter",
  "type": "module",
  "main": "dist/index.js",
  "files": ["dist"],
  "freestyle": {
    "icon": "Sparkles",
    "contributes": {
      "pages": [
        { "id": "profanity-filter", "title": "Profanity Filter", "entry": "dist/ui/index.html" }
      ]
    }
  },
  "scripts": { "build": "pkgroll --minify && vite build" },
  "dependencies": { "freestyle-voice": "workspace:*" }
}
```

## 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.

```ts theme={null}
import type { Plugin, PluginOptions, PluginStorage } from "freestyle-voice";
import { buildMatchers, clean, DEFAULT_REPLACEMENTS } from "./replacements.js";

const STORAGE_KEY = "replacements";

export default function profanityFilter(options?: PluginOptions): Plugin {
  const opts = (options ?? {}) as { preserveCase?: boolean };
  const preserveCase = opts.preserveCase !== false;

  // Mutable state — seeded in setup(), mutated by the CRUD routes.
  let map = {};
  let matchers = buildMatchers(map);
  let storage: PluginStorage | null = null;

  return {
    name: "@freestyle-voice/profanity-filter",

    async setup(ctx) {
      storage = ctx.storage;
      // Load the saved list, or seed the defaults on first run.
      const stored = await storage.get(STORAGE_KEY);
      map = stored && typeof stored === "object" ? stored : { ...DEFAULT_REPLACEMENTS };
      if (!stored) await storage.set(STORAGE_KEY, map);
      matchers = buildMatchers(map);
      ctx.logger.info(`ready on ${ctx.mode} (${matchers.length} substitutions)`);
    },

    afterCleanup(_input, output) {
      output.text = clean(output.text, matchers, preserveCase);
    },
  };
}
```

`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`](/sdk-reference#pluginstorage) (`ctx.storage`), so edits made in the UI survive restarts.

<Tip>
  Need a one-liner instead? The `transform` helper wraps a plain string function into an `afterCleanup` hook:

  ```ts theme={null}
  import { transform } from "freestyle-voice";
  // afterCleanup: transform((text) => text.replace(/\s+$/, "")),
  ```
</Tip>

## 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`:

```ts theme={null}
const ROUTE = "/api/plugins/freestyle-voice-profanity-filter/replacements";

const handler = async (c, next) => {
  if (!c.req.path.startsWith(ROUTE)) return next();

  // GET  -> list all words
  // POST -> add a word { word, alternatives }
  // PUT  -> update a word
  // DELETE -> remove a word
  // POST /reset -> restore defaults
  // ...each mutation updates `map`, rebuilds `matchers`, and calls storage.set()
};

// add to the returned plugin:  middleware: [handler],
```

The page reads and writes through the bridge:

```ts theme={null}
// list
const res = await window.freestyle.api(ROUTE);
const { replacements } = await res.json();

// add
await window.freestyle.api(ROUTE, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ word: "heck", alternatives: ["heck", "gosh"] }),
});
```

## 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:

```json theme={null}
{
  "scripts": {
    "build": "pkgroll --minify && vite build",
    "link": "node ../../scripts/link-plugin.mjs",
    "unlink": "node ../../scripts/link-plugin.mjs --unlink"
  }
}
```

Then, from your plugin's directory:

```bash theme={null}
pnpm run link
```

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.

<Steps>
  <Step title="Restart Freestyle">
    Open (or restart) the app and go to **Settings → Plugins**. Your plugin shows up in the installed list with a `[DEV]` label.
  </Step>

  <Step title="Enable it">
    Toggle it on from there. Linking alone puts the files in place — enabling is what activates its hooks and middleware.
  </Step>

  <Step title="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.
  </Step>
</Steps>

Dictate a curse word and watch it get swapped. When you're done, remove the dev copy:

```bash theme={null}
pnpm run unlink
```

<Note>
  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.
</Note>

### 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:

```json theme={null}
["@freestyle-voice/profanity-filter", { "preserveCase": true }]
```

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:

```ts theme={null}
import type { FreestyleBridge } from "freestyle-voice";

const bridge: FreestyleBridge | undefined = window.freestyle;
if (!bridge) throw new Error("Host bridge unavailable.");

// Read your plugin's data from its own middleware route.
const res = await bridge.api("/api/plugins/freestyle-voice-profanity-filter/replacements");
if (!res.ok) throw new Error(`server returned ${res.status}`);
const data = await res.json();

// POST works too — it's a normal fetch, so any body type is fine.
await bridge.api("/api/transcribe", {
  method: "POST",
  headers: { "content-type": "audio/wav" },
  body: audioBytes,
});

// Host actions.
await bridge.invoke("copy", { text: "copied!" });
await bridge.invoke("toast", { message: "Saved", variant: "success" });
await bridge.invoke("navigate", { to: "/plugins" });
```

See the full shape in the [SDK reference](/sdk-reference#the-ui-bridge).

Next: the full [SDK reference](/sdk-reference).
