SuperAPI AI Directory

heartreverie-create-plugin

Coding & development

View repository

Updated 10-15 23:32

About this skill

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:

  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:

"/<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 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) => {
      // 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 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)
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 call runPluginPrompt.
  • 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 explicit null, 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). Result chapterUpdated: true, appendedTag echoes the tag.
  • { append: true } (no appendTag) — tagless append: the response is trim()-ed and appended verbatim with no wrapper element (no wrapper-stripping pass), so any tags the model emitted survive exactly. Result chapterUpdated: true, appendedTag: null. Use this when the prompt owns its own tag structure (e.g. emits multiple <image> blocks). Passing an explicit appendTag: null, an empty string, or a malformed tag is rejected with plugin-action:invalid-append-tag.
  • { replace: true } — atomic overwrite of the highest-numbered chapter (byte-for-byte rollback on abort/error). Result chapterReplaced: true. The chapter's pre-write content is exposed to the prompt as the reserved Vento variable {{ draft }}. replace is mutually exclusive with append and rejects when appendTag is also set.
  • Neither flag — runs the LLM and returns the raw content; the chapter file is untouched.

Notes:

  • pluginName for runPluginPrompt is auto-curried — the handler MUST NOT pass it.
  • Action buttons are also gated by the universal enabled setting at the API level (/api/plugins/action-buttons filters out disabled plugins) and the click path re-checks settings to no-op stale clicks. The explicit getSettings().enabled === false check above is still recommended as a safety net.
  • extraVariables accepts only scalar values (string / number / boolean) and MUST NOT clash with reserved names (previousContext, any lore_*, status_data, draft).
  • For the full append/replace lifecycle (WS envelope, post-response dispatch, rate limit, concurrency lock), see docs/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?.() (or context.getSettings?.()) at each invocation and return early when enabled === 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), implement getDynamicVariables() instead of a static fragment.
  • Action buttons are filtered by the engine, but click handlers should still check enabled as a stale-cache safety net.
  • Do not rely on enabled to suppress promptStripTags or displayStripTags; 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:

  1. Name match: plugin.json name field matches directory name
  2. Valid JSON: plugin.json parses without errors
  3. File existence: All files referenced in manifest exist (promptFragments[].file, backendModule, frontendModule)
  4. Path safety: All file paths resolve within plugins/<name>/ (no ../ traversal)
  5. system.md integration: If prompt fragments use named variables, confirm {{ variable_name }} exists in system.md
  6. settingsSchema validity (if present): top-level must be type: "object" with a properties record; otherwise it is silently ignored at load time
  7. Run tests: deno test --allow-read --allow-write --allow-env --allow-net to verify nothing is broken