Create Plugin
Create a new plugin for the manifest-driven plugin system. Plugins live in plugins/<name>/ with a plugin.json manifest that declares capabilities.
For full manifest field reference, read references/manifest-schema.md.
Step 1: Understand the Plugin
Determine what the plugin does. Derive:
- Name: kebab-case, e.g.,
my-plugin. Must be valid: no..,\0,/,\. - Directory:
plugins/<name>/ - Purpose: What it adds to the system
Step 2: Determine Plugin Type
Select type based on what the plugin needs:
| Type | Use When |
|---|---|
prompt-only |
Only injects text into the LLM system prompt |
full-stack |
Needs any combination of: prompt fragments, backend hooks, frontend rendering |
hook-only |
Only needs backend lifecycle hooks (no prompt injection) |
frontend-only |
Only browser-side rendering |
When uncertain, ask the user to choose from the four types.
Step 3: Create the Manifest
Create plugins/<name>/plugin.json with required fields:
{
"name": "<name>",
"displayName": "<人類可讀名稱>",
"version": "1.0.0",
"description": "Brief description",
"type": "<type>"
}
name is the slug (must match directory name); displayName is the label rendered in the reader sidebar and settings page heading. Both are required — a plugin missing or with a blank displayName is rejected during load.
Then add type-appropriate optional fields per the patterns below.
Pattern: prompt-only
{
"name": "my-plugin",
"displayName": "我的外掛",
"version": "1.0.0",
"description": "My prompt instructions",
"type": "prompt-only",
"promptFragments": [
{ "file": "./instructions.md", "variable": "my_plugin", "priority": 100 }
]
}
Pattern: full-stack (prompt + frontend + tags)
{
"name": "my-plugin",
"displayName": "我的外掛",
"version": "1.0.0",
"description": "My full-stack plugin",
"type": "full-stack",
"promptFragments": [
{ "file": "./instructions.md", "variable": "my_plugin", "priority": 100 }
],
"frontendModule": "./frontend.js",
"tags": ["mytag"],
"promptStripTags": ["mytag"],
"displayStripTags": ["mytag"],
"hooks": [
{ "stage": "frontend-render", "reads": ["text"], "writes": ["text", "placeholderMap"] }
]
}
Pattern: full-stack (backend + frontend + tags, no prompt)
{
"name": "my-plugin",
"displayName": "我的外掛",
"version": "1.0.0",
"description": "My processing plugin",
"type": "full-stack",
"backendModule": "./handler.js",
"frontendModule": "./frontend.js",
"tags": ["mytag"],
"promptStripTags": ["mytag"],
"hooks": [
{ "stage": "post-response", "parallel": true, "readOnly": true,
"reads": ["usage", "endpoint", "source", "pluginName", "correlationId"] },
{ "stage": "frontend-render" }
]
}
Pattern: hook-only
{
"name": "my-plugin",
"displayName": "我的外掛",
"version": "1.0.0",
"description": "My backend hook plugin",
"type": "hook-only",
"backendModule": "./handler.js",
"hooks": [
{ "stage": "post-response", "writes": ["content"] }
]
}
Critical: The name field must match the directory name exactly. The displayName field is the user-facing label (any non-empty Unicode string after trim); UI surfaces such as the reader sidebar and /settings/plugins/<name> heading render displayName rather than the slug.
hooks is mandatory for new plugins. Enumerate every hooks.register("<stage>", ...) call in register() here. The loader compares manifest vs runtime registration on startup and rolls back the plugin load with a declaredOnly/registeredOnly error on mismatch. The check also powers the Hook Inspector page (/settings/hook-inspector) and the deno task introspect:hooks CLI. Use reads/writes to participate in conflict detection (C1: two plugins writing the same field; C2: read with no writer). Omitting hooks entirely puts the plugin in legacy mode (no validation) — only use this for unmaintained third-party plugins during migration.
For all fields and detailed examples, read references/manifest-schema.md.
Step 4: Create Prompt Fragments (if applicable)
For plugins with promptFragments:
- Create each Markdown file declared in the manifest (e.g.,
plugins/<name>/instructions.md) - Write the LLM instructions content
- If the fragment has a
variable, add{{ variable_name }}tosystem.mdat the desired position
Priority guide:
10— Start of prompt (framing)100— Normal (default)800— Reinforcement (re-emphasize late in prompt)900— End of prompt (final instructions)
For reinforcement patterns (two fragments at different priorities), see the writestyle plugin in references/manifest-schema.md.
Step 5: Configure Tags (if applicable)
If the LLM outputs custom XML tags (e.g., <mytag>...</mytag>):
- Add tag names to
tagsarray - Add to
promptStripTags— strip frompreviousContextso tags don't echo back to LLM - Add to
displayStripTags— strip from frontend display (only if the tag should not be visible to readers)
Plain text for simple tags: "mytag" → auto-wrapped as <mytag>[\s\S]*?</mytag>
Regex for tags with attributes:
"/<mytag\\b[^>]+>[\\s\\S]*?<\\/mytag>/g"
Usually promptStripTags and displayStripTags use the same patterns. They differ when a tag should be stripped from the LLM prompt but kept visible in the reader (or vice versa).
Step 6: Create Backend Module (if applicable)
For plugins with backendModule, create the handler file. Backend modules register handlers via a context object. The module must export a register function that receives { hooks, logger, getSettings } — a PluginHooks wrapper, a scoped Logger, and a zero-arg getSettings() (own-plugin only). The same own-plugin getSettings() is also present on the getDynamicVariables(context) context. registerRoutes(context) additionally exposes saveSettings(values) (validates against the schema then persists). Backend getSettings is NOT cross-plugin — only the frontend hooks.getSettings(name?) / context.getSettings(name?) can read other plugins' settings.
JavaScript (handler.js):
export function register({ hooks, logger }) {
hooks.register("post-response", async (context) => {
const log = context.logger ?? logger;
const { content, storyDir, rootDir } = context;
log.info("Processing response", { contentLength: content.length });
// Process the LLM response
}, 100);
}
TypeScript (handler.ts):
import type { PluginRegisterContext } from "../../writer/types.ts";
export function register({ hooks, logger }: PluginRegisterContext): void {
hooks.register("post-response", async (context) => {
const log = context.logger ?? logger;
const content = context.content as string;
log.info("Processing response", { contentLength: content.length });
// Process the LLM response
}, 100);
}
For the active hook stages and their context parameters, read references/hook-api.md.
Backend code style: ESM, double quotes, semicolons, async/await, JSDoc comments. Use context.logger ?? logger pattern in hook handlers for request-scoped logging.
The same module MAY additionally export registerRoutes(context) (sync or async) to mount custom HTTP endpoints under /api/plugins/<name>/* — useful for proxying external services or backing x-options-url dropdowns in the settings page. See references/hook-api.md for the full PluginRouteContext contract.
Step 7: Create Frontend Module (if applicable)
For plugins with frontendModule, create the module. The register function receives two arguments — a per-plugin hooks proxy and a context object:
import { escapeHtml } from '../_shared/utils.js';
export function register(hooks, context) {
hooks.register('frontend-render', (ctx) => {
const settings = hooks.getSettings();
if (settings.enabled === false) return;
let index = 0;
ctx.text = ctx.text.replace(
/<mytag>([\s\S]*?)<\/mytag>/gi,
(_match, inner) => {
const placeholder = `<!--MYTAG_BLOCK_${index++}-->`;
ctx.placeholderMap.set(placeholder, `<div class="my-component">${escapeHtml(inner)}</div>`);
return placeholder;
}
);
}, 100);
}
Key points:
register(hooks, context)— both args are provided. Older plugins that only declareregister(hooks)keep working.hooks.register(stage, handler, priority?)and the equivalent aliashooks.on(stage, handler, priority?)are both available on the per-plugin proxy. New code may use either; some community plugins feature-detect via(hooks.on ?? hooks.register).hooks.getSettings(name?)andcontext.getSettings(name?)both return the live settings snapshot forname(defaults to the calling plugin). The reader hydrates settings on boot and, after a ~50 ms debounce onplugin-settings:changed, bumps the chapter render epoch — that re-runs render-pipeline hooks (frontend-render,chapter:render:after,chapter:dom:ready) and re-appliesdisplayStripTags. Thenotificationhook is NOT re-dispatched on settings change.registerMAY beasync(the loader awaits it before flippingpluginsReady).hooks.register(stage, handler, priority?)— theoriginPluginNameis auto-curried by the loader proxy; do NOT pass it manually.Frontend handlers are synchronous for most stages;
action-button:clickis the exception (async dispatch). Async handlers on synchronous stages are rejected by the dispatcher and surface a startup mismatch in Hook Inspector. If you mustawaitsomething inside a sync stage, fire-and-forget via an IIFE:hooks.register('frontend-render', (ctx) => { // Sync work that affects ctx must happen here, synchronously. queueMicrotask(async () => { // Side-effects that don't block the render pipeline. await refreshCacheElsewhere(); }); });Use unique placeholder names that include the plugin name to avoid collisions.
Shared utilities live under
/plugins/_shared/. Import them via relative paths (e.g.import { escapeHtml } from '../_shared/utils.js';); the server only serves files under_shared/and each plugin's declaredfrontendModule/frontendStyles/frontendImports.Sibling
.jsimports must be declared inmanifest.frontendImports. Iffrontend.jsdoesimport { foo } from './helper.js';, add"./helper.js"tofrontendImportsor the browser request for/plugins/<name>/helper.jsreturns404. Imports from../_shared/*do not need to be declared. Seereferences/manifest-schema.mdfor the validator rules.Frontend code style: ESM, single quotes, no build step, no framework — plugins ship as raw JS even though the reader itself is a Vue 3 + Vite SPA.
Frontend Hook Stages (quick reference)
| Stage | Mode | When |
|---|---|---|
frontend-render |
sync | Custom XML extraction → placeholder map (Markdown not yet parsed) |
chapter:render:after |
sync | Post-process the rendered token array (chapter HTML chunks); new/edited html tokens are re-sanitized by DOMPurify. Context: { tokens, rawMarkdown, options } — story metadata lives in ctx.options.series / ctx.options.story / ctx.options.chapterNumber. Token shape: RenderToken = { type: 'html', content: string } | { type: 'vento-error', data: ... } — there are NO markdown-it text/paragraph tokens here; markdown is already rendered to HTML chunks. To inspect plain text, parse ctx.rawMarkdown (the original chapter source) instead of walking tokens. |
chapter:dom:ready |
sync | After Vue commits the rendered chapter to the live DOM. Receives { container, tokens, rawMarkdown, chapterIndex, series?, story?, chapterNumber? }. Fires once on mount and again on every [tokens, renderEpoch, isEditing] change — handlers MUST be idempotent (clear prior per-container state at the top). Skipped while the chapter editor is open. |
chapter:dom:dispose |
sync | Only fires when the previous chapter container is unmounted. NOT one-to-one with chapter:dom:ready (no dispose between successive ready events on the same container). Use for final unmount cleanup of long-lived references (Highlight ranges, observers). |
chat:send:before |
sync (pipeline) | Before a user message is sent. Context: { message, series, story, mode }. Return a string to replace ctx.message; return empty string to drop. ctx.mode is 'send' or 'resend'. |
notification |
sync | Lifecycle events (chat:done, chat:error). Receives { event, data, notify }. |
story:switch / chapter:change |
sync (informational) | Navigation events; cannot cancel. |
action-button:click |
async | Triggered by PluginActionBar; dispatcher only invokes handlers owned by the button's plugin. |
For the full context-parameter table, settings-aware patterns, and DOMPurify re-sanitization rules, read references/hook-api.md.
Mounting a Floating Panel
For persistent floating UI (widgets, debuggers, dashboards), the reader always renders a <div id="plugin-panel-slot"> inside MainLayout.vue. It is fixed, full-viewport, z-index: 100, pointer-events: none on the container and pointer-events: auto on direct children — i.e. plugins are expected to mount widgets whose own root elements opt back in to pointer events.
Mount into #plugin-panel-slot rather than directly under document.body. The slot can be torn down and re-created on route changes (e.g. login screen → main reader), so use a MutationObserver on document.body to remount when the slot reappears.
function mountWidget(slot) {
if (slot.querySelector('.my-plugin-root')) return; // idempotent
const root = document.createElement('div');
root.className = 'my-plugin-root';
slot.appendChild(root);
// ... render into `root`
}
function setupSlotObserver() {
const slot = document.getElementById('plugin-panel-slot');
if (slot) mountWidget(slot);
const obs = new MutationObserver(() => {
const s = document.getElementById('plugin-panel-slot');
if (s) mountWidget(s);
});
obs.observe(document.body, { childList: true, subtree: true });
}
See HeartReverie_Plugins/cost-tracker/frontend.js (setupSlotObserver) for a complete reference implementation.
Notification Hook
Frontend modules can register a notification hook, dispatched on events like chat:done. Context: { event, data, notify }. notify({ title, body?, level?, position?, channel?, duration? }) shows a toast.
export function register(hooks) {
hooks.register('notification', (ctx) => {
if (ctx.event !== 'chat:done') return;
const channel = document.visibilityState === 'hidden' ? 'auto' : 'in-app';
ctx.notify({ title: '故事生成完成', level: 'success', channel });
}, 100);
}
Step 8: Add an Action Button (optional)
Action buttons let a plugin contribute an interactive button to the reader's main layout (between UsagePanel and ChatInput). Clicking the button dispatches the action-button:click frontend hook for the owning plugin, where the handler typically calls context.runPluginPrompt(...) to run a plugin-owned prompt file through the same LLM pipeline as normal chat — optionally appending the response (wrapped in a tag) to the latest chapter.
Ask the user whether the plugin should expose an action button. If no, skip this step. If yes, gather:
- Button id (kebab-case, matching
^[a-z0-9-]+$, unique within the plugin) — e.g.recompute-state - Label (1..40 chars, often emoji + zh-TW text) — e.g.
🧮 重算狀態 visibleWhen—"last-chapter-backend"(default; only on the last chapter while in backend mode) or"backend-only"(any backend-mode position where the action bar is mounted). NOTE: the action bar itself is only mounted when chat input would render — i.e.isBackendMode && (isLastChapter || chapters.length === 0). So"backend-only"does NOT make a button appear on historical chapters; it only bypasses the per-button last-chapter filter inside that already-mounted bar.- Prompt file name (optional, but typical) — e.g.
recompute.md. Required when the handler will callrunPluginPrompt. appendTag(optional) — XML tag name used when wrapping the appended response (matching^[a-zA-Z][a-zA-Z0-9_-]{0,30}$). OPTIONAL even with{ append: true }: omit it entirely for a tagless append (the response is appended verbatim with no wrapper element — useful when the prompt already emits its own tag structure, e.g. multiple<image>blocks). An explicitnull, empty string, or malformed tag is rejected.
Then emit the following stubs:
8a. Manifest descriptor
Add an actionButtons entry to plugin.json:
{
"actionButtons": [
{
"id": "<button-id>",
"label": "<label>",
"tooltip": "<short tooltip>",
"priority": 100,
"visibleWhen": "last-chapter-backend"
}
]
}
The button id must be unique within the plugin; duplicates are dropped by the loader with a warning.
8b. Click handler in frontend.js
Extend the plugin's register(hooks, context) with an action-button:click handler. The dispatcher already filters handlers by owning plugin, but always guard on buttonId so a future second button doesn't trigger the wrong handler:
hooks.register('action-button:click', async (ctx) => {
if (ctx.buttonId !== '<button-id>') return;
// Optional: stale-cache safety net for the universal `enabled` setting.
if (hooks.getSettings?.().enabled === false) return;
// Append/replace require an existing chapter file. On a fresh story
// (`lastChapterIndex === null`) both modes will reject — bail with a
// user-visible warning instead of letting runPluginPrompt throw.
if (ctx.lastChapterIndex === null) {
ctx.notify({ title: '尚無章節可更新', level: 'warning' });
return;
}
try {
await ctx.runPluginPrompt('<prompt-file>.md', {
append: true,
appendTag: '<AppendTag>',
// OR: replace: true, // mutually exclusive with append
// OR: extraVariables: { mode: 'fast', count: 3 }, // scalar values only
});
await ctx.reload();
ctx.notify({ title: '<完成標題>', level: 'info' });
} catch (err) {
ctx.notify({
title: '<失敗標題>',
body: err?.message ?? String(err),
level: 'error',
});
// Do NOT re-throw — the dispatcher always emits its own default
// error toast on uncaught throws, which would duplicate this notice.
}
}, 100);
Action-button click context (ctx):
| Field | Description |
|---|---|
buttonId, pluginName |
The clicked button's id and owning plugin |
series, name |
Active story identifiers (always present in backend mode) |
storyDir |
Frontend story identifier formatted as "${series}/${name}" — relative path-like string, NOT a filesystem path |
lastChapterIndex |
0-based index of the latest chapter (= chapters.length - 1), or null if no chapters yet |
runPluginPrompt(file, opts?) |
Auto-curried with this plugin's name. Returns { content, usage, chapterUpdated, chapterReplaced, appendedTag }. |
notify(opts) |
Action-button-specific notify: forwards ONLY title, body, level. level allows 'info' | 'warning' | 'error' (NOT 'success'); position, channel, and duration are dropped — unlike the broader notification hook's notify. |
reload() |
Calls useChapterNav.reloadToLast() |
runPluginPrompt write modes:
{ append: true, appendTag }— atomic append into the highest-numbered chapter, wrapped as<appendTag>…</appendTag>(strips at most one outer matching wrapper first). ResultchapterUpdated: true,appendedTagechoes the tag.{ append: true }(noappendTag) — tagless append: the response istrim()-ed and appended verbatim with no wrapper element (no wrapper-stripping pass), so any tags the model emitted survive exactly. ResultchapterUpdated: true,appendedTag: null. Use this when the prompt owns its own tag structure (e.g. emits multiple<image>blocks). Passing an explicitappendTag: null, an empty string, or a malformed tag is rejected withplugin-action:invalid-append-tag.{ replace: true }— atomic overwrite of the highest-numbered chapter (byte-for-byte rollback on abort/error). ResultchapterReplaced: true. The chapter's pre-write content is exposed to the prompt as the reserved Vento variable{{ draft }}.replaceis mutually exclusive withappendand rejects whenappendTagis also set.- Neither flag — runs the LLM and returns the raw content; the chapter file is untouched.
Notes:
pluginNameforrunPluginPromptis auto-curried — the handler MUST NOT pass it.- Action buttons are also gated by the universal
enabledsetting at the API level (/api/plugins/action-buttonsfilters out disabled plugins) and the click path re-checks settings to no-op stale clicks. The explicitgetSettings().enabled === falsecheck above is still recommended as a safety net. extraVariablesaccepts only scalar values (string / number / boolean) and MUST NOT clash with reserved names (previousContext, anylore_*,status_data,draft).- For the full append/replace lifecycle (WS envelope,
post-responsedispatch, rate limit, concurrency lock), seedocs/plugin-system/overview.md.
8c. Stub prompt file
Create plugins/<name>/<prompt-file>.md. The template MUST emit at least one {{ message "user" }}…{{ /message }} block (plain text in front of any {{ message }} block is treated as system). Minimal stub:
{{ message "system" }}
<!-- Describe the task for the LLM here. -->
{{ /message }}
{{ message "user" }}
<!-- Reference the latest chapter via {{ previous_context }} or any plugin variable. -->
Latest chapter:
{{ previous_context }}
{{ /message }}
Available variables include the core set (previous_context, user_input (defaults to "" for plugin actions), isFirstRound, series_name, story_name, plugin_fragments), all lore_* variables, and any dynamic variables exported by getDynamicVariables() from any plugin's backend module. If you need extra inputs from the click handler, pass them through runPluginPrompt's extraVariables: { ... } option (scalar values only).
8d. README mention
Add a short usage paragraph to the plugin's README.md describing what the button does and when it appears.
Step 8.5: Add Plugin Settings (optional)
If the plugin needs user-configurable values (API endpoints, secret keys, dropdown selections, allow-lists), declare a settingsSchema in the manifest. The reader auto-renders a settings page at /settings/plugins/<name> and exposes GET/PUT /api/plugins/<name>/settings plus GET /api/plugins/<name>/settings-schema. Saved values land in playground/_plugins/<name>/config.json.
Ask: Does the user need to change anything at runtime without editing the plugin source? If yes, add a settingsSchema.
The schema MUST be type: "object" with a properties record (other shapes are rejected at load time). Each property maps to an input widget — see the field-type table in references/manifest-schema.md.
"settingsSchema": {
"type": "object",
"x-schema-version": 1,
"additionalProperties": false,
"properties": {
"endpoint": { "type": "string", "title": "API Endpoint", "format": "url", "default": "https://api.example.com" },
"apiKey": { "type": "string", "title": "API Key", "writeOnly": true },
"model": { "type": "string", "title": "Model", "enum": ["small", "medium", "large"] },
"samplers": { "type": "array", "title": "Allowed Samplers", "items": { "type": "string" }, "x-options-url": "/api/plugins/<name>/proxy/samplers" },
"blocklist": { "type": "array", "title": "Blocked Keywords", "items": { "type": "string" } },
"enabled": { "type": "boolean", "default": true }
}
}
Always declare x-schema-version: 1 on a settings schema. If omitted, the loader auto-migrates it to 1 with a warning; unsupported versions don't block plugin load but cause GET /settings to return defaults and PUT /settings to respond 409. Secrets MUST use writeOnly: true — the supported format keywords are path, color, url, email, uuid; format: "password" is silently ignored, so always pair sensitive fields with writeOnly: true. writeOnly values are masked as null in GET responses and never logged.
Backend handlers read settings via the getSettings() helper present on every backend register context: register({ hooks, logger, getSettings }), registerRoutes(context), and getDynamicVariables(context) all receive it. Backend getSettings() is zero-arg, own-plugin only — it cannot read other plugins' settings. Mutations go through saveSettings(...) which is exposed ONLY on registerRoutes(context) (it validates against the schema before writing); register() and getDynamicVariables() cannot persist settings. Avoid reading playground/_plugins/<name>/config.json directly — only fall back to it as a last-resort legacy path.
Frontend modules read live settings synchronously via hooks.getSettings(name?) or context.getSettings(name?) (see Step 7). A successful PUT /api/plugins/:name/settings broadcasts plugin-settings:changed; after a ~50 ms debounce the reader bumps the chapter render epoch, re-running frontend-render, chapter:render:after, and chapter:dom:ready and re-applying displayStripTags. notification is not re-dispatched on settings change.
Universal enabled checklist
When a plugin exposes settings, add an enabled boolean with default: true unless there is a strong reason not to. Then verify:
- Frontend hooks call
hooks.getSettings?.()(orcontext.getSettings?.()) at each invocation and return early whenenabled === false. - Backend hooks call
getSettings?.()at execution time and no-op when disabled. - Prompt fragments may rely on the engine to suppress
promptFragments[]when disabled. If a setting changes fragment text (not just enable/disable), implementgetDynamicVariables()instead of a static fragment. - Action buttons are filtered by the engine, but click handlers should still check
enabledas a stale-cache safety net. - Do not rely on
enabledto suppresspromptStripTagsordisplayStripTags; strip-tag declarations intentionally remain active for historical content.
Step 9: Generate README.md
Create plugins/<name>/README.md in Traditional Chinese (zh-TW):
- Use full-width punctuation(,、。:;「」)
- Add space between Chinese and alphanumeric characters
- Sections:
概述、manifest 欄位說明、檔案說明、使用方式or運作方式
Template:
# <name>
## 概述
<Description in zh-TW>
## manifest 欄位說明
| 欄位 | 說明 |
|------|------|
| ... | ... |
## 檔案說明
| 檔案 | 說明 |
|------|------|
| `plugin.json` | Plugin manifest |
| ... | ... |
## 使用方式
<Usage instructions in zh-TW>
Step 10: Validate
Run these checks before considering the plugin complete:
- Name match:
plugin.jsonnamefield matches directory name - Valid JSON:
plugin.jsonparses without errors - File existence: All files referenced in manifest exist (
promptFragments[].file,backendModule,frontendModule) - Path safety: All file paths resolve within
plugins/<name>/(no../traversal) - system.md integration: If prompt fragments use named variables, confirm
{{ variable_name }}exists insystem.md settingsSchemavalidity (if present): top-level must betype: "object"with apropertiesrecord; otherwise it is silently ignored at load time- Run tests:
deno test --allow-read --allow-write --allow-env --allow-netto verify nothing is broken