How-to › Document and support developers

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.

Audience
Library maintainer
Level
intermediate
Topic
Ship docs with the code
Languages
JavaScript and TypeScript
Verified

Your plugin’s README lists four messages, and one of them is a name you renamed in March. A reader in node_modules opens the README, sends sys:meter,load:meter, and gets act_not_found. The table was right when it was written. Nothing in CI reads it, so nothing failed when the code moved on without it.

What you get

You will end up with a README section that seneca-doc rewrites from the running plugin, a typed twin of the same patterns, and a script that diffs every written copy against seneca.list(). This is for you if you publish a Seneca plugin and its README is written by hand.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hand-maintained Markdown tableNarrative matters more than the list, or the plugin is small and rarely changesA rename that nobody carries into the table, and nothing that fails when the two disagreeThe plugin registers more than a handful of patterns, or renames them
seneca-docThe reference must match what a running instance registers, and it must ship in the READMEA doc file keyed by function name, markers in the README, and a list that covers only the default configurationA message depends on an option and you will not register it unconditionally
TypeDocThe plugin is TypeScript and the message types are the contract you want written upA tsconfig, a second build step, and a description of the types that nothing checks against the registered patternsThe 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.

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.

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

npx seneca-doc && sed -n '/START:action-list/,/END:action-list/p' README.md
<!--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.

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

TypeDoc 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 to an empty list, so the compile does not pick up every @types package that happens to sit in a parent node_modules.

npx typedoc
[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') 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.

node compare.mjs
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.

node compare.mjs ./meter-conditional.js
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.

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

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'],
  })
})
node --test patterns.test.mjs
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.

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.

Footnotes

  1. The command’s package 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. ↩︎ Back to text

  2. The README 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. ↩︎ Back to text

  3. The markers survive rendering because of a rule older than any of the tools here. CommonMark 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 -->. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.