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

# Writing a plugin

> Teach Narrator what a library or your own helpers mean, step by step.

Without a plugin, Narrator reads library calls from their names. That gets you a long way, but it can't know that `new Decimal(a).lte(b)` is a comparison or that `Flags.isEnabled("x")` is a feature check. A plugin adds that knowledge. This guide builds up the decimal.js plugin from the repository, then covers rule keys, guards and testing.

## What a plugin changes

Here is a small plugin for an in-house feature flag helper and a retry wrapper, before and after:

```ts flags-plugin.ts theme={null}
import { D } from "@usenarrator/core";
import { definePlugin, unwrap } from "@usenarrator/lang-ts";
import { narrate } from "@usenarrator/testkit";

type FlagPhrases = {
	enabled: (p: { flag: string; neg: boolean }) => string;
	withRetries: (p: { action: string; times: string }) => string;
};

const flags = definePlugin<FlagPhrases>({
	name: "flags",
	phrases: {
		en: {
			enabled: ({ flag, neg }) => `the ${flag} feature is ${neg ? "off" : "on"}`,
			withRetries: ({ action, times }) => `${action}, retrying up to ${times} times`,
		},
	},
	vocabulary: { glossary: { cfg: "settings" } },
	conditions: {
		"Flags.isEnabled": ({ args, neg, say }) => {
			const flag = unwrap(args[0]);
			if (flag?.type !== "Literal") return null;
			return say.enabled({ flag: String(flag.value), neg });
		},
	},
	calls: {
		withRetry: ({ args, e, say }) => {
			if (args.length !== 2) return null;
			return D(say.withRetries({ action: e.text(args[0]), times: e.text(args[1]) }));
		},
	},
});

const code = `
if (!Flags.isEnabled("new-checkout")) return legacyCheckout(cfg);
await withRetry(chargeCard(order), 3);
`;

console.log("Without the plugin:");
console.log(narrate(code));
console.log("\nWith the plugin:");
console.log(narrate(code, { plugins: [flags] }));
```

```text Output theme={null}
Without the plugin:
If "new-checkout" is not enabled [Flags], give back a legacy checkout (config).
With retry (charge card (order) and 3).

With the plugin:
If the new-checkout feature is off, give back a legacy checkout (settings).
Charge card (order), retrying up to 3 times.
```

Three things happened. A condition rule turned `Flags.isEnabled("new-checkout")` into a sentence about a feature. A call rule turned `withRetry(...)` into "retrying up to 3 times". The `vocabulary` field changed how the abbreviation `cfg` reads. The rest of the file is still narrated by the built-in rules.

## Build the decimal plugin

The [decimal plugin](/plugins/decimal) is about 80 lines and uses every common feature, so it is a good model.

<Steps>
  <Step title="Describe your phrasebook">
    A plugin never hard-codes English inside its rules. It declares the shape of everything it says as a type, so that other locales can provide the same phrases.

    ```ts phrases.ts theme={null}
    type Pair = (p: { a: string; b: string }) => string;
    type Is = (p: { value: string; neg: boolean }) => string;

    export type DecimalPhrases = {
      plus: Pair;
      minus: Pair;
      times: Pair;
      dividedBy: Pair;
      roundedTo: (p: { value: string; places: string }) => string;
      compare: Record<"atMost" | "lessThan" | "atLeast" | "moreThan" | "equals" | "notEquals", Pair>;
      isZero: Is;
      isNegative: Is;
      isPositive: Is;
    };
    ```

    Arguments arrive as already-rendered text, with [markup](/concepts/markup) included, so phrases only arrange words.
  </Step>

  <Step title="Write the English phrases">
    English is required. It is the fallback for every other locale and the reference that translators compare against.

    ```ts en.ts theme={null}
    import type { DecimalPhrases } from "./phrases";

    const is =
      (word: string) =>
      ({ value, neg }: { value: string; neg: boolean }) =>
        `${value} is ${neg ? "not " : ""}${word}`;

    export const en: DecimalPhrases = {
      plus: ({ a, b }) => `${a} plus ${b}`,
      minus: ({ a, b }) => `${a} minus ${b}`,
      times: ({ a, b }) => `${a} times ${b}`,
      dividedBy: ({ a, b }) => `${a} divided by ${b}`,
      roundedTo: ({ value, places }) => `${value} rounded to ${places} decimal places`,
      compare: {
        atMost: ({ a, b }) => `${a} is at most ${b}`,
        lessThan: ({ a, b }) => `${a} is less than ${b}`,
        atLeast: ({ a, b }) => `${a} is at least ${b}`,
        moreThan: ({ a, b }) => `${a} is more than ${b}`,
        equals: ({ a, b }) => `${a} is ${b}`,
        notEquals: ({ a, b }) => `${a} is not ${b}`,
      },
      isZero: is("zero"),
      isNegative: is("negative"),
      isPositive: is("positive"),
    };
    ```
  </Step>

  <Step title="Write call rules">
    A call rule turns a call into a noun phrase, such as "price times quantity". It receives the call's receiver (`obj`), its arguments (`args`), the engine (`e`) and your phrasebook (`say`).

    ```ts theme={null}
    const arithmetic =
      (op: "plus" | "minus" | "times" | "dividedBy"): CallRule<DecimalPhrases> =>
      ({ obj, args, e, say }) => {
        const a = e.noun(obj);
        const b = e.noun(args[0]);
        return D(say[op]({ a: a.t, b: b.t }), [...a.kids, ...b.kids]);
      };
    ```

    `e.noun(node)` renders any expression and returns a `Doc`: the inline text `t` plus any block lines (`kids`) the expression needed, such as a multi-line callback. Passing the kids through keeps those lines attached. When you only need the inline text, `e.text(node)` does this for you.
  </Step>

  <Step title="Write condition rules">
    A condition rule is used when a call appears where a yes or no answer is expected, such as an `if`. It also receives `neg`, which is `true` when the condition is negated. Use it to say the opposite naturally instead of "it is not the case that".

    ```ts theme={null}
    const comparison =
      (holds: Comparison, negated: Comparison): CondRule<DecimalPhrases> =>
      ({ obj, args, e, neg, say }) => {
        if (args.length !== 1 || !obj) return null;
        const a = e.noun(obj);
        const b = e.noun(args[0]);
        return D(say.compare[neg ? negated : holds]({ a: a.t, b: b.t }), [...a.kids, ...b.kids]);
      };
    ```

    A negated `lte` reads as "is more than", and a negated `gt` reads as "is at most".
  </Step>

  <Step title="Put it together">
    Rules are keyed by the callee they handle. `*.plus` matches `.plus(...)` on any receiver.

    ```ts index.ts theme={null}
    export const decimal = (): TsPlugin<DecimalPhrases> =>
      definePlugin({
        name: "decimal",
        phrases: { en },
        calls: {
          "new Decimal": ({ args, e }) => e.noun(args[0]),
          "*.plus": arithmetic("plus"),
          "*.add": (x) => (isNewDecimal(x.obj) ? arithmetic("plus")(x) : null),
          "*.minus": arithmetic("minus"),
          "*.times": arithmetic("times"),
          "*.dividedBy": arithmetic("dividedBy"),
          "*.toNumber": ({ obj, e }) => e.noun(obj),
          "*.toDecimalPlaces": ({ obj, args, e, say }) => D(say.roundedTo({ value: e.text(obj), places: e.text(args[0]) })),
        },
        conditions: {
          "*.lte": comparison("atMost", "moreThan"),
          "*.lt": comparison("lessThan", "atLeast"),
          "*.gte": comparison("atLeast", "lessThan"),
          "*.gt": comparison("moreThan", "atMost"),
          "*.isZero": sign("isZero"),
        },
      });
    ```

    The real plugin has a few more aliases (`sub`, `mul`, `div`, `eq`). `definePlugin` is an identity function that infers the phrasebook type from `phrases.en`, so every rule's `say` is fully typed.
  </Step>

  <Step title="Try it">
    ```ts theme={null}
    const total = new Decimal(price).times(quantity).plus(shipping);
    if (!new Decimal(balance).gte(total)) throw new Error("Insufficient funds");
    const rounded = total.toDecimalPlaces(2);
    ```

    ```text English theme={null}
    Let total be price times quantity plus shipping.
    If balance is less than total, fail with an error: "Insufficient funds".
    Let rounded be total rounded to 2 decimal places.
    ```
  </Step>
</Steps>

## Rule keys

The engine looks up call rules by the shape of the callee. A call can match several keys, and they are tried from most to least specific.

| Key | Matches | Example |
| - | - | - |
| `foo` | A call to a bare identifier | `withRetry(fn, 3)` |
| `Obj.method` | A method on a specific identifier | `z.object({...})`, `Flags.isEnabled("x")` |
| `Obj.*` | Any method on a specific identifier | `Effect.*` |
| `*.parent.method` | A method on a property with a given name | `*.charges.create` for `stripe.charges.create(...)` |
| `*.method` | A method on any receiver | `*.lte`, `*.filter` |
| `new Foo` | A constructor call | `new Decimal(x)` |
| `Obj.fn()` | The outer call of a curried call | `Effect.fn("name")(function* () {...})` |
| ` tag` \`\` | A tagged template | `` sql`select ...` `` |
| ` *.tag` \`\` | A tagged template on any receiver | `` db.sql`...` `` |
| ` tag.fn()` \`\` | A tagged template whose tag is itself a call | `` sql.type<Row>()`...` `` |

A tagged template rule receives the template literal as `args[0]`.

When several plugins register the same key, the plugin listed later wins, and the built-in `std` plugin always comes first. If a rule returns `null` or `undefined`, the next rule for that key is tried, then the next key, and finally the engine's generic phrasing.

### Key grammar

In the keys below, `name` is a JavaScript identifier (letters, digits, `_` and `$`, not starting with a digit), and `*` is a literal wildcard.

* **Call and condition keys** are one of `name`, `new name`, `name.name`, `new name.name`, `name.*`, `new name.*`, `*.name`, `*.name.name`, a curried call `inner()`, or a tagged template ` inner` `or` inner()` `, where `inner` is `name`, `name.name` or `*.name`.
* **Member keys** are one of `name.name`, `name.*`, `*.name`, `*.name.name` or `*.name.*`.

A bare call key may also be any other name without spaces, dots, parentheses or backticks, for plugins that rewrite the syntax tree and give a node a made-up callee. Any other key can never match, so the engine prints a `console.warn` naming the plugin and the key the first time a plugin with such a key is loaded. `"Foo..bar"`, `"z object"` and `"new *.x"` are all reported.

### Imports under another name

Keys are written with the names a library exports, but code often imports them under other names. When your plugin declares its `library`, a call rule also matches calls that reach your library's export through an import:

| Code | Matches the key |
| - | - |
| `import { addDays as plus } from "date-fns"`, then `plus(d, 1)` | `addDays` |
| `import * as dfn from "date-fns"`, then `dfn.addDays(d, 1)` | `addDays` |
| `import dfn from "date-fns"`, then `dfn.addDays(d, 1)` | `addDays` |
| `import { z as zod } from "zod"`, then `zod.object({})` | `z.object` |

The import must come from `library.name` or one of its subpaths, such as `date-fns/fp`. Plugins without a `library` only match the names as written. This applies to call and condition rules, including `new`, curried and tagged forms of a call; member rules match the names as written.

```ts import-aliases.ts theme={null}
import { definePlugin } from "@usenarrator/lang-ts";
import { narrate } from "@usenarrator/testkit";

const dates = definePlugin({
	name: "dates",
	library: { name: "date-fns", versions: ">=3 <5" },
	calls: {
		addDays: ({ args, e }) => (args.length === 2 ? `${e.text(args[1])} days after ${e.text(args[0])}` : null),
	},
});

const code = `
import { addDays as plus } from "date-fns";
import * as dfn from "date-fns";
const due = plus(issuedAt, 30);
const followUp = dfn.addDays(due, 7);
`;

console.log(narrate(code, { plugins: [dates] }));
```

```text Output theme={null}
Let due be 30 days after issued at.
Let follow up be 7 days after due.
```

## Member rules and JSX

Calls aren't the only thing a library gives meaning to. `members` rules read property access, such as `c.env.DB` in a Cloudflare Worker or `env.STRIPE_KEY` in a config module, and a `jsx` rule reads JSX elements wherever the engine meets one: in arguments, in lambdas and in returns. The [React plugin](/plugins/react) uses a `jsx` rule to read JSX as UI.

Member rules are keyed like call rules, from most to least specific: `Obj.prop`, `*.owner.prop`, `*.prop`, then the families `Obj.*` and `*.owner.*`. A rule receives the member expression as `node`, its object as `obj` and the property name as `prop`. When a rule claims an inner link of a longer chain, such as `c.env.DB` inside `c.env.DB.prepare`, that link becomes the owner of the rest of the chain.

```ts members-and-jsx.ts theme={null}
import { definePlugin } from "@usenarrator/lang-ts";
import { narrate } from "@usenarrator/testkit";

const platform = definePlugin({
	name: "platform",
	members: {
		// `env.STRIPE_KEY` -> "the STRIPE_KEY setting"
		"env.*": ({ prop }) => `the ${prop} setting`,
		// `req.session.user` on any receiver -> "the signed-in user"
		"*.session.user": () => "the signed-in user",
	},
	calls: {
		// A tagged template: `` gql`...` ``
		"gql``": ({ e }) => (e.usesLibrary("graphql-tag") ? "a GraphQL query" : null),
	},
	jsx: ({ node, e }) => {
		const name = node.openingElement?.name?.name;
		if (name !== "Feature") return null;
		const flag = node.openingElement.attributes.find((a: any) => a.name?.name === "flag");
		return `the children, only when the ${e.text(flag.value)} flag is on`;
	},
});

const code = `
import gql from "graphql-tag";
const key = env.STRIPE_KEY;
await audit(req.session.user, "export");
const Query = gql\`query { viewer { id } }\`;
const banner = <Feature flag="new-nav"><Nav /></Feature>;
`;

console.log(narrate(code, { plugins: [platform], path: "app.tsx" }));
```

```text Output theme={null}
Let key be the STRIPE_KEY setting.
Run audit (the signed-in user and "export").
Let query be a GraphQL query.
Let banner be the children, only when the "new-nav" flag is on.
```

Member rules apply where a property is read as a value. A property tested as a condition, as in `if (!req.session.user)`, still reads as the property chain.

## Guards

`*.method` keys are broad. `*.add` matches decimal.js, but it also matches `set.add(x)` and `cart.add(item)`. A rule that claims calls it doesn't understand makes narration worse, so check the shape first and return `null` when it isn't yours.

Common guards in the repository's plugins:

* **Check that the file uses your library.** `e.usesLibrary("hono")` is `true` when the file imports `hono` (or a subpath such as `hono/cors`), or when its package depends on Hono. Almost every library plugin checks this, so a `.get()` in an Express app or an unrelated `Effect` class is left alone. See [Dependency detection](/concepts/dependency-detection).
* **Check where a name came from.** `e.imports` maps each imported local name to its module and exported name, so `e.imports.get("Link")` tells you whether `Link` is `next/link`'s default export or someone else's component.
* **Check the receiver.** The decimal plugin only claims `*.add` when the receiver is a `new Decimal(...)` expression.
* **Check the arguments.** Comparisons require exactly one argument. The flags example above only claims `Flags.isEnabled` when its argument is a string literal.

Plugins that need more facts about a file, such as which variables hold a Stripe client, compute them once per file from `e.program`, the file's syntax tree, and cache them by engine or by `e.program`. `e.path` gives the file's path, which the Next.js plugin uses to read routes.

### When a rule fails

A rule that throws, or returns something other than a `Doc`, a string or `null`, doesn't stop narration. The engine treats it as if the rule had returned `null`: the next rule for that key gets a try, then the engine's own phrasing. Any lines the rule emitted before it failed are removed. The same goes for `statements` rules (which should return `true` or `false`), the `jsx` rule and `describeSchema`.

Each failure becomes a `problems` entry on the narration, with `source: "plugin"`, `severity: "warning"`, the plugin's name, the rule (for example `calls["*.plus"]` or `statements[0]`), the line it happened on and the error message. `stats.ruleErrors` holds the same failures with a count of how often each happened. A host can log them:

```ts theme={null}
const { problems } = narrator.narrate(source, "billing.ts");
for (const p of problems) if (p.source === "plugin") console.warn(`${p.plugin} ${p.rule} failed on line ${p.line}: ${p.message}`);
```

In tests, `narrate` and `narrateWithStats` from `@usenarrator/testkit` throw when any rule fails, so a buggy rule fails the test instead of quietly falling back.

Returning `null` is still the right answer for anything a rule doesn't recognise, because a failure costs a little time and leaves a warning behind.

## Other plugin fields

| Field | What it does |
| - | - |
| `vocabulary` | Adds glossary entries (`{ cfg: "settings" }`), acronyms, verbs and predicate prefixes used to read identifiers. |
| `ambient` | Names that are plumbing, such as `ctx` or `deps`. They are dropped from argument lists and member chains. |
| `errors` | Constructor names (or a pattern) whose instances are errors, so `throw new X("message")` reads well. |
| `errorLabels` | How to name an error class, for example `{ HttpError: "an HTTP error" }`. |
| `constants` | Exact source text mapped to a phrase, for example `{ "Number.MAX_SAFE_INTEGER": "no limit" }`. |
| `members` | Rules for property reads, keyed as described in [Member rules and JSX](#member-rules-and-jsx). |
| `jsx` | One rule for JSX elements and fragments. Return `null` to leave an element to the engine or a later plugin. |
| `statements` | Rules that take over a whole statement and emit lines themselves. Effect uses these for `yield*` steps. |
| `describeSchema` | Describes a schema builder expression as a type. zod and Effect Schema use it so fields read as types. |
| `library` | The library and version range the plugin reads. See [Plugin versioning](/guides/plugin-versioning). |

## Type your phrasebook for translators

Locales can override a plugin's phrases under `locale.plugins[name]`. To make those overrides type-checked, add your phrasebook type to the `PluginPhrases` interface in `@usenarrator/core`:

```ts theme={null}
declare module "@usenarrator/core" {
  interface PluginPhrases {
    decimal: DecimalPhrases;
  }
}
```

The key must match your plugin's `name`. See [Translating Narrator](/guides/translating#how-plugin-phrases-are-chosen).

The full type is in the [TsPlugin reference](/reference/ts-plugin).

## Test it

`@usenarrator/testkit` narrates a snippet with your plugin and returns plain text, which makes exact assertions easy. Every plugin in the repository is tested this way.

```ts decimal.test.ts theme={null}
import { expect, test } from "bun:test";
import { narrateWithStats, phrasebookGaps } from "@usenarrator/testkit";
import { decimal, decimalEn } from "../src";

const reads = (code: string, text: string) => {
  const r = narrateWithStats(code, { plugins: [decimal()] });
  expect(r.text).toBe(text);
  expect(r.fallbacks).toBe(0);
};

test("arithmetic chains read left to right", () =>
  reads(`const total = new Decimal(price).times(quantity).plus(fee);`, "Let total be price times quantity plus fee."));

test("comparisons and their negations", () => {
  reads(`if (new Decimal(balance).lte(0)) stop();`, "If balance is at most 0, stop.");
  reads(`if (!new Decimal(balance).lte(0)) go();`, "If balance is more than 0, run go.");
});

test("a partial translation reports what it is missing", () => {
  const gaps = phrasebookGaps(decimalEn, { plus: () => "", compare: { atMost: () => "" } });
  expect(gaps.missing).toContain("compare.moreThan");
});
```

Asserting `fallbacks` is zero catches the case where part of your snippet silently fell back to raw code. See the [testkit reference](/reference/testkit) for the full API.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.