# Writing a provider

Source: https://heyparlour.app/docs/providers

> Add a voice, an engine or an integration as an npm package.

You can replace any stage of Parlour with your own provider and choose it by
name in `config.json`. Built-in providers are files under
`packages/parlour/src/providers`. Your own is an npm package with a default
export, and Parlour loads it without any change to Parlour itself.
Integrations (a source of tools, prompt lines and a mute gate) are providers
of kind `integration` and work the same way.

This guide explains how it works, then builds a real one. If you have ever
wrapped a command line tool in a function, you already know most of it.

## How a name becomes a provider

`resolveProvider(kind, name, options, context)` in `core/providers.ts` does
the work:

1. It looks up `(kind, name)` in the registry. The built-ins are there
   because their modules call `registerProvider` when imported.
2. If nothing is registered, it runs `await import(name)`. A name containing
   `..` is refused. An absolute path is allowed, so you can try a provider
   from a checkout before you publish it. The module's `default` export is
   accepted when it is an object whose `kind` matches, whose `name` is a
   string and whose `create` is a function. It is then registered under the
   name from config, so the next lookup finds it straight away.
3. It parses the options with the definition's `schema`, if there is one.
   A slice that fails is reported with the provider's name and one line per
   problem, path first.
4. It calls `create(options, context)` and returns the result, awaiting it
   if it is a promise. The context can be a function of the definition
   instead of a plain value. That is how the agent scopes each provider's
   logger by the provider's own `name` rather than the specifier from config,
   so a provider loaded by absolute path still logs as `piper`, not as the
   path.

Only a missing provider is reported as unknown, with the registered
alternatives listed so you can spot a typo. A package that exists but fails
to load (a syntax error, a missing peer, an old Node) is reported as a load
failure, and one whose `kind` is wrong for the slot says which kind it is.

Your provider gets the whole slice from config. For
`"tts": { "provider": "x", "voice": "y" }` it is handed
`{ provider: "x", voice: "y", fallback: "macos-say" }`. Core fills in no
`voice` or `speed` of its own, because the same slice also goes to the
fallback voice, so each provider's schema supplies its own defaults. A
`z.object` schema quietly drops the keys it does not declare, which is what
you want here.

## The definition

```ts
export type ProviderKind =
  | "audioSource" | "audioSink" | "wake" | "stt" | "tts"
  | "llm" | "decision" | "search" | "secrets" | "service" | "integration";

export interface ProviderContext {
  paths: Paths;                       // configFile, secretsFile, modelsDir, logsDir...
  secrets: Secrets;                   // core's own: haToken, anthropicKey, braveKey, token, logLevel
  log: Logger;                        // scoped to the provider's name
  emit: (event: AgentEvent) => void;  // the NDJSON event stream, a no-op unless --events
  /** The whole config, for providers that need more than their own slice. */
  config: unknown;
}

export interface ProviderDefinition<T = unknown> {
  kind: ProviderKind;
  name: string;
  description: string;
  /** Validates the provider's own slice of config. Defaults to "anything". */
  schema?: z.ZodType;
  create(options: unknown, context: ProviderContext): T | Promise<T>;
}

export function defineProvider<T>(definition: ProviderDefinition<T>): ProviderDefinition<T>;
```

`defineProvider` is the identity function. It lets TypeScript infer `T` and
makes a package's default export read as what it is. `options` arrives as
`unknown` even when there is a schema, so `create` casts it to the schema's
inferred type, as every built-in does.

### A secret of your own

`context.secrets` holds only the keys core reads for itself: `haToken`,
`anthropicKey`, `braveKey`, `token` and `logLevel`. If your provider talks to
a hosted service, read its key from the environment. Before any command
runs, `parlour` copies every line of `secrets.env` into `process.env`
(anything already set in the shell wins). So by the time `create` is called,
`process.env.MY_KEY` holds whatever you gave `parlour secrets set MY_KEY`.
Follow the built-in `openai-compatible` provider and take the variable's
*name* as an option, not the value itself:

```ts
const schema = z.object({ apiKeyEnv: z.string().default("MY_SERVICE_API_KEY") });

function create(options: Options, context: ProviderContext) {
  const apiKey = process.env[options.apiKeyEnv];
  // If it is missing, say so from doctor() with the name to set, rather than throwing here.
}
```

This keeps the key out of `config.json`, which is the rule for every
provider (see the [contributing guide](https://github.com/mrprkr/parlour/blob/main/CONTRIBUTING.md)).
People can rename the variable if they want to. And `parlour secrets status`
reports it: every key in `secrets.env` is listed there, the four core ones by
purpose and the rest as `in secrets.env`.

The package root exports everything a provider needs: `defineProvider`, the
`ProviderContext` and `ProviderDefinition` types, every port interface,
`Check`, `defineTool` and `Tool` for integrations, and the message types a
`ChatModel` sees. Fakes for every port come from `parlour/testing`.

## Worked example: Piper as a voice

[Piper](https://github.com/rhasspy/piper) is a fast local text to speech
engine. Its command line reads text on stdin and writes a WAV. Here it
becomes a `tts` provider.

`package.json`:

```json
{
  "name": "parlour-tts-piper",
  "version": "0.1.0",
  "description": "Piper as a Parlour voice",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "scripts": { "build": "tsc" },
  "peerDependencies": { "parlour": ">=0.1.0" },
  "dependencies": { "zod": "^4.0.0" },
  "devDependencies": { "typescript": "^5.9.0", "@types/node": "^22.0.0" }
}
```

`"type": "module"` matters. Parlour loads the package with `import()`, and a
CommonJS module's `default` export is not the `module.exports` object you
might expect. `parlour` is a peer dependency because the definition is
imported from it, and there must be only one copy.

`index.ts`:

```ts
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { type Check, defineProvider, type ProviderContext, type TextToSpeech } from "parlour";
import { z } from "zod";

// Only this provider's own keys. The rest of the tts slice (provider,
// fallback, and any voice or speed meant for another engine) is dropped by
// z.object rather than rejected.
const schema = z.object({
  /** A voice from https://github.com/rhasspy/piper/blob/master/VOICES.md, without the extension. */
  model: z.string().default("en_GB-alba-medium"),
  /** Where the .onnx and .onnx.json live. Defaults to Parlour's own model cache. */
  modelsDir: z.string().optional(),
});

type Options = z.infer<typeof schema>;

/** piper reads the text on stdin and writes the file it was told to. */
function piper(model: string, text: string, file: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const child = spawn("piper", ["--model", model, "--output_file", file], {
      stdio: ["pipe", "ignore", "pipe"],
    });
    let stderr = "";
    child.stderr.on("data", (chunk: Buffer) => {
      stderr += chunk.toString();
    });
    child.on("error", reject);
    child.on("close", (code) =>
      code === 0 ? resolve() : reject(new Error(`piper exited ${code}: ${stderr.trim()}`)),
    );
    child.stdin.end(text);
  });
}

function createPiper(options: Options, context: ProviderContext): TextToSpeech {
  const dir = options.modelsDir ?? join(context.paths.modelsDir, "piper");
  const model = join(dir, `${options.model}.onnx`);

  return {
    // Nothing to load: piper is a process per sentence, and the model is
    // read from disk each time. A provider with a model to load would do
    // that here, so the first reply does not wait for it.
    async warm() {},

    async render(text) {
      // Rendered to a temporary file rather than stdout so a stray line of
      // logging from piper cannot end up inside the WAV.
      const work = await mkdtemp(join(tmpdir(), "parlour-piper-"));
      const file = join(work, "sentence.wav");
      try {
        const started = Date.now();
        await piper(model, text, file);
        context.log.debug(`rendered in ${Date.now() - started}ms`);
        return await readFile(file);
      } finally {
        await rm(work, { recursive: true, force: true });
      }
    },

    // Throwing from render is how a voice fails. Core's FallbackTextToSpeech
    // catches it and speaks through tts.fallback instead, so this never
    // needs a fallback of its own.

    async doctor(): Promise<Check[]> {
      if (!existsSync(model)) {
        return [
          {
            name: "Piper",
            status: "fail",
            detail: `${model} is missing. Download the voice and its .json from the Piper voices list.`,
          },
        ];
      }
      return [{ name: "Piper", status: "ok", detail: `voice ${options.model}` }];
    },
  };
}

export default defineProvider<TextToSpeech>({
  kind: "tts",
  name: "parlour-tts-piper",
  description: "Piper, a fast local voice with a command line",
  schema,
  create: (options, context) => createPiper(options as Options, context),
});
```

`tsconfig.json`, so the output is ES modules with declarations:

```json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "nodenext",
    "strict": true,
    "declaration": true,
    "outDir": "dist",
    "skipLibCheck": true
  },
  "include": ["index.ts"]
}
```

Install it next to Parlour and name it in config:

```sh
npm install -g parlour-tts-piper
```

```json
{ "tts": { "provider": "parlour-tts-piper", "model": "en_GB-alba-medium" } }
```

A global install works because `npm install -g` puts every global package in
one `node_modules`, which Node finds by walking up from Parlour's own files.

### Trying it against a checkout

To try it before publishing, or against a Parlour checkout, point config at
the built file with an absolute path:
`"provider": "/Users/you/src/parlour-tts-piper/dist/index.js"`.

To build that file you need a `parlour` to compile against, and the peer
dependency alone does not provide one. `npm install` fetches peers from the
registry, and fails when the version it wants is not published there. Add
the checkout as a dev dependency instead, which satisfies the peer too:

```json
"devDependencies": { "parlour": "file:/Users/you/src/parlour/packages/parlour" }
```

Build the checkout first (`pnpm -C packages/parlour build`). The link points
at the package directory, and its exports point at `dist`. Running `npm link`
in `packages/parlour` and then `npm link parlour` in the provider does the
same, but it replaces any globally installed `parlour` with the checkout.

Keep the link in place at run time, not just for `tsc`. `defineProvider` is
a function, so the `import ... from "parlour"` in the built file is a real
import. Node resolves it by walking up from the provider's own directory, not
Parlour's. Without a `node_modules/parlour` next to the provider,
`parlour doctor` reports that the package failed to load with
`Cannot find package 'parlour'`, whichever Parlour is loading it.

Then:

```sh
parlour doctor      # the Piper check appears alongside the others
parlour text        # nothing is spoken here, but the provider is created
parlour start
```

## The other kinds

The same shape fills any slot. `create` returns the port for its kind:

| Kind | Returns | Options come from |
| --- | --- | --- |
| `audioSource` | `AudioSource` | `audio` (the whole object; `inputDevice`, `sampleRate`...) |
| `audioSink` | `AudioSink` | `audio` |
| `wake` | `WakeWordEngine` | `wake` (`words`, `threshold`, `refractoryMs` and your own) |
| `stt` | `SpeechToText` | `stt` |
| `tts` | `TextToSpeech` | `tts` |
| `llm` | `ChatModel` | `llm.local` or `llm.cloud`, whichever names you |
| `decision` | `DecisionModel` | `llm.decision` (on-device triage; built-in `laya-mlx`) |
| `search` | `SearchProvider` | `search` |
| `integration` | `Integration` | `integrations["your-name"]` |

`secrets` and `service` providers are chosen by platform, not named in
config. To add one (a systemd `ServiceManager`, say), choose it by platform
in `pickServiceManager` in `packages/parlour/src/providers/service/index.ts`,
or in `pickSecretStore` in `providers/secrets/index.ts` for a secret store. That makes it a pull
request rather than a package, and one that would be very welcome.

## An integration

An integration is a provider of kind `integration` whose `create` returns:

```ts
export interface Integration extends Diagnosable {
  readonly name: string;
  tools(): Promise<Tool[]>;
  /** Extra lines for the system prompt. */
  promptContext?(): string[];
  /** True means ignore this wake. Asked after the wake word, before anything is acted on. */
  gate?(): Promise<boolean>;
  close?(): Promise<void>;
}
```

You turn an integration on by adding it as a key under `integrations`. The
key is the provider name, so `"integrations": { "parlour-integration-sonos": {} }`
loads that package and hands it `{}`.

One thing to know: the block replaces the default rather than adding to it.
If you write one by hand and still want the house and the connectors, keep
`"home-assistant": {}` and `"connectors": {}` in it. (`parlour connectors add`
writes `connectors.json`, never this block.) `parlour doctor` warns when a
connector is listed that the config never loads.

Build tools with `defineTool(name, description, jsonSchema, handler)` from
the package root. The handler returns a string the model reads. Keep names
stable and short. Every tool description sits in the local model's context on
every turn, so a package with forty tools makes every answer slower.

`tools()` is called once at assembly, so a connection opened there stays
open. Close it in `close()`. The Home Assistant integration in
`packages/parlour/src/integrations/home-assistant/index.ts` is a good
reference: MCP tools, two REST tools, a prompt line, a gate that reads an
entity, and a doctor that checks the token, the API and the MCP endpoint.

## Testing

`parlour/testing` exports a fake for every port: `FakeChatModel`,
`FakeDecisionModel` (with `triageAnswers` to build the scores it returns),
`FakeSpeechToText`, `FakeTextToSpeech`, `FakeAudioSource`, `FakeAudioSink`,
`FakeWakeWordEngine` (and the `FakeWakeWordDetector` it hands out, with
`wakeFrame` to make one fire), `FakeSearchProvider`, `FakeSecretStore`,
`FakeServiceManager` and `FakeIntegration`, plus `silentLogger`. Each one
records what it was asked. They are the same fakes Parlour's own session and
pipeline tests use, so your provider or integration can be tested with no
hardware, model or network. A `ProviderContext` for a test takes only a few
lines:

```ts
import { resolvePaths, type ProviderContext } from "parlour";
import { silentLogger } from "parlour/testing";

const context: ProviderContext = {
  paths: resolvePaths({ HOME: "/tmp/parlour-test" }),
  secrets: {},
  log: silentLogger,
  emit: () => {},
  config: {},
};
```

Providers that need hardware or a model are not unit tested in this
repository either. Each has a `doctor()` instead. Get that part right,
because it is what people see when something does not work.
