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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A hand-maintained Markdown table | 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 | 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 | 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.
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.
Related how-tos
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
-
The command’s package lists eight files to publish, and three of them are the whole tool.
inspect.jsbuilds an instance and loads the module named inmain,render.jsturns the plugin description into Markdown, andinject.jsswaps the text between the markers. The instance it builds is created withlegacy: falseand the plugin is loaded withinit$: false, so a plugin whoseinitaction opens a database connection is documented without it. ↩︎ Back to text -
The README says the export
doc/generatingwill betruewhile documentation is being generated. Inlib/inspect.jsthe value is passed only whenseneca.options().legacy.optionsis on, and the command creates its instance withlegacy: false, so the export comes outundefined. 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 -
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