# How to document Seneca plugin message patterns inside the repo

> Generate the pattern reference from the running plugin with seneca-doc, ship it in the README, and diff it against seneca.list() to catch messages an option adds.

Source: https://voxgig.com/howto/document-seneca-plugin-message-patterns

- Audience: library-maintainer
- Level: intermediate
- Languages: javascript, typescript
- Verified: 2026-09-25
- Published: 2026-09-25

## Short answer

Run seneca-doc in the plugin repository. It loads the plugin with its default options, asks the running instance for its patterns, and writes the list and the descriptions from meter-doc.js into README.md between HTML comment markers. The reference ships with the package and cannot name a pattern the plugin does not register. It records only what the defaults register, so register every message unconditionally and let the option gate the reply.

---
## You will need

Node 22 or later and a Seneca plugin that registers message patterns with
[`add`](https://senecajs.org/api/). Verified 2026-09-25 against Node 22.22.2, seneca 3.38.0,
@seneca/doc 8.0.0, seneca-promisify 3.7.2, `typedoc` 0.28.20 and `typescript` 5.9.3. The plugin here
is `meter`, a metering service with four messages, one of which depends on an option.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A hand-maintained Markdown table](https://github.github.com/gfm/#tables-extension-) | Narrative matters more than the list, or the plugin is small and rarely changes | A rename that nobody carries into the table, and nothing that fails when the two disagree | The plugin registers more than a handful of patterns, or renames them |
| [seneca-doc](https://github.com/senecajs/seneca-doc) | The reference must match what a running instance registers, and it must ship in the README | A doc file keyed by function name, markers in the README, and a list that covers only the default configuration | A message depends on an option and you will not register it unconditionally |
| [TypeDoc](https://typedoc.org/documents/Doc_Comments.html) | The plugin is TypeScript and the message types are the contract you want written up | A tsconfig, a second build step, and a description of the types that nothing checks against the registered patterns | The plugin is JavaScript, or the types and the patterns can drift apart |

Voxgig maintains Seneca and seneca-doc. This page compares them with a hand-maintained table and
with TypeDoc, and both alternatives carry more narrative than seneca-doc does.

The three differ in what they read. The table reads the author's memory, TypeDoc reads the types,
and seneca-doc reads the instance. Only the last of those is what a caller gets, and only the last
has no way to describe a message the instance did not register.

## Write the descriptions beside the plugin

seneca-doc takes the pattern list from the instance and the prose from a file named after the
plugin, `meter-doc.js`. The keys are the action function names, so `list_meters` has to be a named
function in `meter.js`, and the value is a description, examples, and the reply shape.

```js title="meter-doc.js"
module.exports = {
  sections: {
    overview: { path: 'doc/overview.md' },
  },
  messages: {
    list_meters: {
      desc: 'List the meters the service knows about, optionally at one site.',
      examples: {
        'site:hq': 'Only the meters installed at site hq.',
      },
      reply_desc: {
        ok: true,
        meters: ['{ id, site, kind }'],
      },
    },
```

`sections` is the narrative escape hatch: each entry names a Markdown file whose whole content is
copied between a `SECTION:<name>` pair of markers. The README carries four pairs of markers,
`SECTION:overview`, `action-list`, `action-desc` and `options`, each written as
`<!--START:name-->` and `<!--END:name-->` on lines of their own. It also carries a table a person
maintains, kept here on purpose so the two can be compared.

```md title="README.md"
| Pattern | Does |
| --- | --- |
| `sys:meter,list:meters` | List meters, optionally by site |
| `sys:meter,load:meter` | Load one meter by id |
| `sys:meter,record:reading` | Append a reading to a meter |
| `sys:meter,export:csv` | Export meters as CSV, when the export option is on |
```

The command reads `main` from package.json, loads that module into a fresh instance, and rewrites
everything between each pair of markers.[^1] It prints nothing, so the second half of the line
shows what it wrote.

```bash
npx seneca-doc && sed -n '/START:action-list/,/END:action-list/p' README.md
```

```text output
<!--START:action-list-->


## Action Patterns

* [sys:meter,export:csv](#-sysmeterexportcsv-)
* [sys:meter,get:meter](#-sysmetergetmeter-)
* [sys:meter,list:meters](#-sysmeterlistmeters-)
* [sys:meter,record:reading](#-sysmeterrecordreading-)


<!--END:action-list-->
```

The generated list shows four patterns, sorted, with `get:meter` rather than `load:meter`, because
the instance was asked rather than the author. The `action-desc` block further down gets a heading per pattern with the
description, the examples, and the reply shape, and the `options` block lists the plugin's option
keys with their types.

## Record the same patterns as types

The TypeDoc route describes the messages as TypeScript interfaces, one per pattern, with the
literal-typed properties spelling the pattern and a doc comment on each parameter.

```ts title="src/meter.ts"
/** Load one meter by id, with its readings. */
export interface LoadMeter {
  sys: 'meter'
  load: 'meter'
  /** The meter id, such as `m1`. */
  id: string
}
```

[TypeDoc](https://typedoc.org/documents/Doc_Comments.html) reads the comments, runs them through
a Markdown parser, and can emit the whole reflection as JSON instead of a site, which is what the
comparison below reads. The tsconfig sets [`types`](https://www.typescriptlang.org/tsconfig/#types)
to an empty list, so the compile does not pick up every `@types` package that happens to sit in a
parent `node_modules`.

```bash
npx typedoc
```

```text output
[info] json generated at ./api.json
```

Nothing in that run touched the plugin. The interface in `src/meter.ts` still says `load`, and
TypeDoc had no way to know.

## Diff every written copy against the running plugin

`compare.mjs` reads the four places a pattern can be written down, plus what a live instance
answers to [`seneca.list('sys:meter')`](https://senecajs.org/api/) under each option set, and
prints them as one matrix. The instance is loaded twice, with `export` off and on, because a
documentation run only ever sees one configuration.

```bash
node compare.mjs
```

```text output
seneca 3.38.0 | @seneca/doc 8.0.0 | typedoc 0.28.20
plugin: ./meter.js

source                       export:csv      get:meter       list:meters     load:meter      record:reading
seneca.list(), export:false  yes             yes             yes             -               yes
seneca.list(), export:true   yes             yes             yes             -               yes
seneca-doc, default options  yes             yes             yes             -               yes
README generated list        yes             yes             yes             -               yes
README hand table            yes             -               yes             yes             yes
typedoc api.json             yes             -               yes             yes             yes

against the instance loaded with export:true
  seneca-doc, default options matches
  README generated list       matches
  README hand table           not registered: sys:meter,load:meter; registered but absent: sys:meter,get:meter
  typedoc api.json            not registered: sys:meter,load:meter; registered but absent: sys:meter,get:meter
```

The two hand-written sources agree with each other and disagree with the plugin. That is the
usual shape of documentation drift: the rename happened in one file, and the reference was two
files away. The generated list matches under both option sets, and the next section is why.

## Load the plugin twice to find the conditional message

The first version of this plugin registered `export:csv` inside an `if`, so the message existed
only when the plugin was loaded with `export: true`. `meter-conditional.js` keeps that version, and
the same comparison against it shows what the documentation run misses.

```bash
node compare.mjs ./meter-conditional.js
```

```text output
plugin: ./meter-conditional.js

source                       export:csv      get:meter       list:meters     record:reading
seneca.list(), export:false  -               yes             yes             yes
seneca.list(), export:true   yes             yes             yes             yes
seneca-doc, default options  -               yes             yes             yes

against the instance loaded with export:true
  seneca-doc, default options registered but absent: sys:meter,export:csv
```

seneca-doc loads the plugin with no options, so it lists three messages, and a reader who turns
the option on finds a fourth that no reference mentions. The generated list cannot drift, and it
also cannot see past the defaults. Its README describes a `doc/generating` export for plugins that
need to behave differently while documentation is generated, and under the command line that
export is `undefined`.[^2]

The fix is in the plugin, not the tool. Register the message whatever the options say, and let
the option decide the reply.

```js title="meter.js"
  function export_csv(msg, reply) {
    if (!options.export) return reply({ ok: false, why: 'export-disabled' })
    const rows = [...meters.values()]
      .filter((m) => null == msg.site || m.site === msg.site)
      .map((m) => [m.id, m.site, m.kind, m.readings.length].join(','))
    reply({ ok: true, csv: ['id,site,kind,readings', ...rows].join('\n') })
  }
```

A caller with the option off now gets `why: 'export-disabled'` instead of `act_not_found`. That
is the better answer anyway. The message exists, this instance declines it, and the reason is in
the reply rather than in a stack trace. The description in `meter-doc.js` says the same thing.

## Check it worked

Six tests with the [Node test runner](https://nodejs.org/api/test.html) pin each cell of the
matrix. The generated list equals the registered list under both option sets, the conditional
plugin loses exactly one pattern, and the two hand-written sources carry exactly the stale name.

```ts title="patterns.test.mjs"
test('a conditionally registered message never reaches the generated list', async () => {
  const off = await registered('./meter-conditional.js', { export: false })
  const on = await registered('./meter-conditional.js', { export: true })
  assert.deepEqual(drift(off, on), { stale: [], missing: ['sys:meter,export:csv'] })
  assert.deepEqual(drift(await described('./meter-conditional.js'), on), {
    stale: [],
    missing: ['sys:meter,export:csv'],
  })
})
```

```bash
node --test patterns.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
```

`described` sends `sys:doc,describe:plugin`, the message the command itself uses, so the test
checks the list seneca-doc would write and not a copy of it.

## When it goes wrong

Every description reads `No description provided.` The doc file was not found. seneca-doc looks
for `<plugin>-doc.js` beside the module named in `main`, so a file called `meterdoc.js` or a
plugin whose function is named differently from its file is skipped without a message. Name the
file after the plugin, or attach the same object as a `doc` property on the plugin function.

One description is right and the others are missing. The keys in `messages` are matched against
the action function's `name`, and an arrow function has none. `this.add('sys:meter,a:1', (msg,
reply) => ...)` looks up the empty string, so give every action a named function.

The command runs, exits 0, and the README is unchanged. The markers are missing or misspelled. The
command replaces text between `<!--START:name-->` and `<!--END:name-->` and says nothing when a
pair is absent, so the first run against a README with no markers changes nothing.[^3] Add the
pairs and run it again.

## When not to do this

Do not adopt seneca-doc for the prose. It carries a sentence per message, a list of examples, and a
reply shape, and a section is a Markdown file it copies in. Anything longer than that belongs in the
README outside the markers, or in TypeDoc: a tutorial, a sequence diagram, a discussion of when to
send which message. Neither tool will keep that prose accurate for you.

Do not run seneca-doc and stop reading `seneca.list()`. The generated list is right for the
configuration the command loads and blind to every other one. A message behind an option, a
message added by a sub-plugin the defaults do not load, a message registered in `init`: none of
them appear. The comparison script is what turns the generated list into a check.

Do not keep a hand table beside the generated one once the generated one exists. Two lists of the
same patterns in one file is a standing invitation to update one of them.

Do not describe a Seneca plugin with TypeDoc alone. The types only say what a message should look
like, since nothing in the build compares them with what `add` registered.

## Related how-tos

- [Ship a changelog inside your published package](/howto/ship-a-changelog-inside-the-package)

- [Test a plugin through every lifecycle stage](/howto/test-a-plugin-through-every-lifecycle-stage)

## Last verified

Verified 2026-09-25 against Node 22.22.2, seneca 3.38.0, @seneca/doc 8.0.0, seneca-promisify 3.7.2,
`typedoc` 0.28.20 and `typescript` 5.9.3. Every output block is what the command preceding it printed,
run in the page's code directory after `npm ci`.

[^1]: The command's [package](https://github.com/senecajs/seneca-doc) lists eight files to
publish, and three of them are the whole tool. `inspect.js` builds an instance and loads the module named in
`main`, `render.js` turns the plugin description into Markdown, and `inject.js` swaps the text
between the markers. The instance it builds is created with `legacy: false` and the plugin is
loaded with `init$: false`, so a plugin whose `init` action opens a database connection is
documented without it.

[^2]: The [README](https://github.com/senecajs/seneca-doc) says the export `doc/generating` will
be `true` while documentation is being generated. In `lib/inspect.js` the value is passed only
when `seneca.options().legacy.options` is on, and the command creates its instance with
`legacy: false`, so the export comes out `undefined`. Measured on 8.0.0 by loading the plugin the
way the command does and reading the export. The hook is described, and the code that would set
it is behind a flag the code itself turns off.

[^3]: The markers survive rendering because of a rule older than any of the tools here.
[CommonMark](https://spec.commonmark.org/0.31.2/#html-blocks) lists seven kinds of HTML block,
and the second starts on a line beginning with `<!--` and ends on the line containing `-->`. The
comment passes through to the HTML untouched, which is what makes it a marker. The same rule is
why a stray `<!--` in a README swallows everything down to the next `-->`.