- 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:
```json
{
"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
```json
{
"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)
```json
{
"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)
```json
{
"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
```json
{
"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`:
1. Create each Markdown file declared in the manifest (e.g., `plugins/<name>/instructions.md`)
2. Write the LLM instructions content
3. 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>`):
1. Add tag names to `tags` array
2. Add to `promptStripTags` โ strip from `previousContext` so tags don't echo back to LLM
3. 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:
```json
"/<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`):**
```javascript
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`):**
```typescript
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`](./references/hook-api.md#registerroutes-export) 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:
```javascript
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:
```javascript
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 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`](./references/manifest-schema.md#frontend-imports-allowlist) 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) |
View on GitHub