Parlour

Writing a provider

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

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:

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). 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 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:

{
  "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:

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:

{
  "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:

npm install -g parlour-tts-piper
{ "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:

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

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:

KindReturnsOptions come from
audioSourceAudioSourceaudio (the whole object; inputDevice, sampleRate...)
audioSinkAudioSinkaudio
wakeWakeWordEnginewake (words, threshold, refractoryMs and your own)
sttSpeechToTextstt
ttsTextToSpeechtts
llmChatModelllm.local or llm.cloud, whichever names you
decisionDecisionModelllm.decision (on-device triage; built-in laya-mlx)
searchSearchProvidersearch
integrationIntegrationintegrations["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:

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:

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.

On this page