| name | heartreverie-create-plugin |
| description | Create a new plugin for the HeartReverie plugin system. Use when the user wants to create a plugin, add a new plugin, scaffold a plugin, or build a plugin for this project. Guides through plugin type selection, manifest creation, prompt fragments, backend/frontend modules, tag configuration, and README generation. |
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 }} to system.md at 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
tags array
- Add to
promptStripTags — strip from previousContext so 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 });
}, 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 });
}, 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 declare register(hooks) keep working.
-
hooks.register(stage, handler, priority?) and the equivalent alias hooks.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?) and context.getSettings(name?) both return the live settings snapshot for name (defaults to the calling plugin). The reader hydrates settings on boot and, after a ~50 ms debounce on plugin-settings:changed, bumps the chapter render epoch — that re-runs render-pipeline hooks (frontend-render, chapter:render:after, chapter:dom:ready) and re-applies displayStripTags. The notification hook is NOT re-dispatched on settings change.
-
register MAY be async (the loader awaits it before flipping pluginsReady).
-
hooks.register(stage, handler, priority?) — the originPluginName is auto-curried by the loader proxy; do NOT pass it manually.
-
Frontend handlers are synchronous for most stages; action-button:click is the exception (async dispatch). Async handlers on synchronous stages are rejected by the dispatcher and surface a startup mismatch in Hook Inspector. If you must await something inside a sync stage, fire-and-forget via an IIFE:
hooks.register('frontend-render', (ctx) => {
queueMicrotask(async () => {
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 declared frontendModule / frontendStyles / frontendImports.
-
Sibling .js imports must be declared in manifest.frontendImports. If frontend.js does import { foo } from './helper.js';, add "./helper.js" to frontendImports or the browser request for /plugins/<name>/helper.js returns 404. Imports from ../_shared/* do not need to be declared. See references/manifest-schema.md for 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) |