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

# SDK reference

> Everything you can do in a Freestyle plugin.

Import everything from `freestyle-voice`.

```ts theme={null}
import {
  transform, OutputMode, FreestyleEventType, PipelineStage,
  type Plugin, type PluginContext, type PluginOptions,
  type FreestyleEvent, type AppContext, type HookApi,
  type PluginLlm, type CleanupToneDestination,
} from "freestyle-voice";
```

## Plugin

The object your factory returns. The default export is `(options?: PluginOptions) => Plugin | Plugin[] | false | null | undefined`. Return an array to ship a preset of plugins, or a falsy value to disable based on options.

```ts theme={null}
interface Plugin {
  name: string;                       // required, stable identifier
  enforce?: "pre" | "post";           // run first or last across all hooks
  setup?: (ctx: PluginContext) => void | Promise<void>;
  dispose?: () => void | Promise<void>;
  middleware?: MiddlewareHandler[];   // Hono handlers (server)
  // plus any of the hooks below
}
```

## Hooks

Each hook is optional. Mutating hooks receive three arguments: a read-only `input`, an `output` you change in place, and a [`HookApi`](#hookapi) (`api`) for cancellation/suppression control and — on server hooks — the host's LLM. They chain across plugins in resolved order (`enforce: "pre"` → none → `"post"`, then load order).

| Hook                                   | Runs                                 | Do                                                                                                                          |
| -------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| `setup(ctx)`                           | on load                              | read settings, init state                                                                                                   |
| `dispose()`                            | on unload                            | clean up                                                                                                                    |
| `beforeTranscribe(input, output, api)` | before speech-to-text \[server]      | preprocess `output.audio`, override `output.providerId`/`modelId`/`language`/`bias`, or `api.control.consume()` to skip STT |
| `afterTranscribe(input, output, api)`  | after speech-to-text \[server]       | rewrite `output.text`, or `api.control.consume()` to skip cleanup + delivery                                                |
| `beforeCleanup(input, output, api)`    | building the AI prompt \[server]     | push to `output.system`, set `output.destination`, `output.prompt`, or `output.skip`                                        |
| `afterCleanup(input, output, api)`     | after cleanup + dictionary \[server] | rewrite `output.text`                                                                                                       |
| `beforeOutput(input, output, api)`     | before delivery \[app]               | rewrite `output.text`, set `output.mode` (no `api.llm` here)                                                                |
| `event({ event })`                     | throughout \[both]                   | observe only, no mutation                                                                                                   |
| `config(existing)`                     | boot \[server]                       | return partial config to deep-merge                                                                                         |

Hook inputs:

* `beforeTranscribe` → `{ providerId, modelId, audioDurationMs, appContext? }`
* `afterTranscribe` → `{ providerId, modelId, appContext? }`
* `beforeCleanup` → `{ text, appContext?, destination }`
* `afterCleanup` and `beforeOutput` → `{ appContext? }`

`beforeCleanup`'s `destination` is a `CleanupToneDestination` — `"overall" | "personal" | "work" | "email"` — the tone bucket the host inferred; set `output.destination` to override it.

```ts theme={null}
beforeOutput(input, output) {
  if (output.text.startsWith("!")) {
    output.text = "";
    output.mode = OutputMode.None; // eat it as a command
  }
}
```

### Consuming an utterance

A plugin can handle an utterance entirely instead of dictating it — for example, a voice command that ran an action. Call `api.control.consume()` from a server hook (typically `afterTranscribe`): the host **skips every remaining stage and delivers nothing** to the focused app.

```ts theme={null}
afterTranscribe(input, output, api) {
  if (isCommand(output.text)) {
    runCommand(output.text);
    api.control.consume("ran a voice command"); // no cleanup, no output
  }
}
```

<Note>`api.control.consume()` replaces the old `output.consumed` flag. It is unambiguous (a genuinely empty transcript is different from a consumed one) and preserves the raw text for logging.</Note>

## HookApi

The third argument to every mutating hook. Built once per dictation and threaded through every stage, so `control` and `llm` are consistent across the whole run.

```ts theme={null}
interface HookApi {
  control: PipelineControl;  // cancellation + suppression for this dictation
  signal: AbortSignal;       // alias for control.signal
  llm?: PluginLlm;           // [server hooks only] the host's LLM, when configured
}

class PipelineControl {
  readonly state: "running" | "consumed" | "aborted";
  readonly reason: string | undefined;
  readonly signal: AbortSignal;
  stopPropagation(): void;   // stop later plugins for THIS hook only
  consume(reason?: string): void; // handled: skip the rest of the pipeline, deliver nothing
  abort(reason?: string): void;   // hard-stop: deliver nothing, emit a pipelineError event
}
```

* **`stopPropagation()`** — stop running the remaining plugins for the *current hook only*; later hooks still run.
* **`consume(reason?)`** — the utterance is handled (e.g. a voice command). Every remaining stage is skipped and no output is delivered.
* **`abort(reason?)`** — unrecoverable failure: nothing is delivered and the host emits a `pipelineError` event. Aborting also fires `control.signal`, so pass `api.signal` to cancellable work (e.g. `api.llm.generateText({ signal: api.signal })`).

The host checks `control.state` between stages. `llm` is present only on server hooks (`beforeTranscribe`, `afterTranscribe`, `beforeCleanup`, `afterCleanup`) and only when an LLM is configured — never on `beforeOutput`. Always guard with `if (api.llm)`.

## PluginContext

Passed to `setup`.

```ts theme={null}
interface PluginContext {
  name: string;
  mode: "server" | "app";
  directory: string;           // absolute path to the user-data dir
  logger: PluginLogger;        // debug / info / warn / error
  settings: SettingsReader;    // read-only settings access
  storage: PluginStorage;      // read/write per-plugin key-value storage
}

interface SettingsReader {
  get(key: string): string | undefined;     // a global setting
  getOwn(key: string): string | undefined;  // this plugin's namespaced setting
}
```

<Note>The LLM capability moved off `PluginContext`. It is now on the per-hook [`HookApi`](#hookapi) as `api.llm`, so it can reflect the model configured for each dictation.</Note>

<Note>Settings are read-only. Pass runtime config through factory `options` and read it back with `getOwn`. For state your plugin needs to write, use `storage`.</Note>

## PluginLlm

Access to the host's configured language model, so server-side plugins can run their own LLM calls (classification, tool-calling agents, and so on) **reusing the user's configured cleanup model and stored keys** — no separate provider or key configuration required. Reached through [`api.llm`](#hookapi) on any server hook.

```ts theme={null}
interface PluginLlm {
  readonly providerId: string; // e.g. "openai", "groq", "freestyle-cloud"
  readonly modelId: string;
  getModel(): unknown;         // an AI SDK LanguageModel — cast at the call site
  generateText(opts: {         // convenience wrapper over the host's generateText
    prompt: string;
    system?: string;
    signal?: AbortSignal;
  }): Promise<{ text: string; usage?: { inputTokens: number; outputTokens: number } }>;
}
```

`api.llm` is present **only on server hooks** and **only when a model is configured**, so always guard with `if (api.llm)`. Read it fresh in each hook — don't capture it in `setup` (it's built per-dictation):

```ts theme={null}
import { generateText, type LanguageModel } from "ai";

async afterTranscribe(input, output, api) {
  if (!api.llm) return; // no model configured — leave the transcript as-is
  const model = api.llm.getModel() as LanguageModel;
  const { text } = await generateText({ model, prompt: output.text, abortSignal: api.signal });
  output.text = text;
}
```

For a simple one-shot call you can skip the AI SDK entirely and use the wrapper:

```ts theme={null}
async afterTranscribe(input, output, api) {
  if (!api.llm) return;
  const { text } = await api.llm.generateText({ prompt: output.text, signal: api.signal });
  output.text = text;
}
```

`getModel()` is typed `unknown` in the SDK to avoid a hard dependency on the `ai` package — cast the result to `LanguageModel` (from `ai`) at the call site. Bundle `ai` in your plugin's `devDependencies`; installed plugins don't get a transitive `npm install`.

<Note>**Freestyle Cloud.** Signed-in Freestyle Cloud users get `api.llm` too — it routes to Freestyle Cloud's managed LLM endpoint, so plugins work without the user configuring their own provider or key. Plugins never see credentials either way: the host resolves the provider, model, and key and hands back only the capability.</Note>

## PluginStorage

Per-plugin persistent key-value storage, scoped by plugin name so plugins never collide. Values are JSON-serialized into the host database (and sync across machines when the database is synced). Think of it as `localStorage` for plugins.

```ts theme={null}
interface PluginStorage {
  get<T = unknown>(key: string): Promise<T | undefined>;
  set(key: string, value: unknown): Promise<void>;
  delete(key: string): Promise<void>;
}
```

Capture it in `setup` and use it from your hooks or middleware:

```ts theme={null}
setup(ctx) {
  const saved = await ctx.storage.get<MyState>("state");
  // ...
  await ctx.storage.set("state", nextState);
}
```

## Events

```ts theme={null}
const FreestyleEventType = {
  RecordingStarted: "recordingStarted",
  RecordingCommitted: "recordingCommitted",
  RecordingCancelled: "recordingCancelled",
  Transcribed: "transcribed",
  Cleaned: "cleaned",
  OutputDelivered: "outputDelivered",
  PipelineError: "pipelineError",
};

type FreestyleEvent =
  | { type: "recordingStarted" }
  | { type: "recordingCommitted" }
  | { type: "recordingCancelled" }
  | { type: "transcribed"; text: string; durationInSeconds?: number }
  | { type: "cleaned"; before: string; after: string }
  | { type: "outputDelivered"; text: string; mode: OutputMode }
  | { type: "pipelineError"; stage: PipelineStage; message: string };
```

`PipelineStage` is `"capture" | "transcribe" | "cleanup" | "transform" | "output"`.

## AppContext

Best-effort info about the focused app, on several hook inputs.

```ts theme={null}
interface AppContext {
  appName?: string;
  windowTitle?: string;
  url?: string;
  bundleId?: string;
}
```

## OutputMode

How a transcript is delivered. Set `output.mode` in `beforeOutput`.

```ts theme={null}
const OutputMode = {
  Paste: "paste",          // copy, then simulate Cmd/Ctrl+V
  Clipboard: "clipboard",  // copy only
  None: "none",            // suppress
};
```

## transform

Wraps a plain text function into an `afterCleanup` hook.

```ts theme={null}
function transform(
  fn: (text: string, input: AfterCleanupInput) => string | Promise<string>,
): Handler<AfterCleanupInput, { text: string }>;
```

## The UI bridge

Freestyle injects `window.freestyle` into plugin pages as the one privileged surface: a helper to call the local server API and to trigger a small set of host actions. See [a worked example](/first-plugin#the-api-bridge) in the first-plugin guide.

```ts theme={null}
interface FreestyleBridge {
  readonly serverUrl: string;  // origin the page is served from (location.origin)
  api(path: string, init?: RequestInit): Promise<Response>;
  invoke<C extends keyof HostActions>(channel: C, payload: HostActions[C]): Promise<void>;
}

interface HostActions {
  copy: { text: string };
  toast: { message: string; variant?: "info" | "success" | "error" };
  navigate: { to: string };
}
```

**`api(path, init?)`** resolves `path` against `serverUrl` and returns a **native `Response`**. Plugin UI is now served same-origin with the server, so this is a thin wrapper over `fetch` (no proxying, no manual token) — call `res.json()` / `res.text()` as usual.

```ts theme={null}
const res = await window.freestyle.api("/api/plugins/my-plugin/data");
if (res.ok) console.log(await res.json());
```

<Note>Plugin pages may only reach their own `/api/plugins/<slug>/…` namespace plus `/api/health` — the host confines them by the request `Referer`. They cannot read settings, keys, or history.</Note>

**`invoke(channel, payload)`** asks the host to do something:

```ts theme={null}
await window.freestyle.invoke("copy", { text: "copied!" });
await window.freestyle.invoke("toast", { message: "Done", variant: "success" });
await window.freestyle.invoke("navigate", { to: "/plugins" });
```

`window.freestyle` is only present inside a hosted plugin page, so guard for it (`if (!window.freestyle) ...`) before use.

## Manifest

The `freestyle` field in `package.json`.

```json theme={null}
{
  "freestyle": {
    "icon": "Sparkles",
    "contributes": {
      "pages": [
        { "id": "my-page", "title": "My Page", "entry": "dist/ui/index.html", "icon": "Settings" }
      ]
    }
  }
}
```

`icon` is a [lucide](https://lucide.dev) name.
