# What a GhostBoard plugin is URL: https://ghostboard.21blog.com/docs/start/overview/ GhostBoard is a local-first spatial canvas for notes and research material. A **plugin** extends it with ordinary JavaScript that runs inside the app's renderer with the same trust as the app itself. There is no sandbox, permission prompt list or marketplace review: you decide which plugins to install, and you enable each one per workspace after reading a high-trust notice. ## What a plugin can contribute | Contribution | What the user sees | Template | | ---------------------- | --------------------------------------------------------------------------------------- | -------------------------- | | **Command** | An entry in the plugin menu with an optional shortcut | default | | **Panel** | A side view rendered with DOM or with the host's shared React | default, `--react` | | **Custom canvas type** | A new kind of card that persists its own state, migrates data and is searchable | `--combined` | | **Importer** | A host-owned "select file → map fields → preview → confirm" workflow that creates Notes | `--importer`, `--combined` | Every plugin can also read and update Notes in revision-checked transactions, subscribe to content and selection changes, keep JSON key-value storage per app or per workspace, schedule tasks while the workspace is active, and read or write user-selected files and directories through the host. ## What the host provides - The runtime for `@ghostboard/sdk`, `@ghostboard/sdk/react`, `react`, `react-dom`, `react-dom/client` and `react/jsx-runtime` through an import map. A no-build plugin is a `manifest.json` plus JavaScript files; nothing is installed or compiled on the user's machine. - Public theme variables (`--gb-*`) so your UI matches light and dark mode without importing host components. - Automatic reload of a connected development directory, with failed reloads leaving the previous version running. - Deterministic packaging, Ed25519 author signing, pinned update sources, code rollback, data-migration backups and a startup safe mode. ## What you need - **GhostBoard 1.0.0 or later on macOS** (Apple Silicon has packaged evidence; Intel is pending verification). Windows and Linux do not support plugins yet. See [Supported platforms and limits](/docs/policy/support-and-limits/). - **Bun 1.2+** for the author tooling: `bunx create-ghostboard-plugin`, author tests and `bun run package`. Node 20+ works for scaffolding; the generated scripts assume Bun. - Basic web knowledge. React is optional; the default templates are plain JavaScript. Continue with [Build your first plugin in 15 minutes](/docs/start/first-plugin/). --- # Install and enable a plugin URL: https://ghostboard.21blog.com/docs/start/install-and-enable/ This page is for people who _use_ plugins. No developer tools are needed: a plugin package is a single `.ghostboard-plugin` (or `.zip`) archive. ## Install a package 1. Open **Settings → Plugins**. 2. Under **Install a package**, either **Choose package…** to pick a local archive, or paste a **Package URL** and click **Inspect link**. URLs must be public `https://` addresses; loopback, private-network and redirected-to-private targets, non-ZIP responses and oversized downloads are rejected. 3. GhostBoard checks the archive without executing any code and shows **Review package**: name, ID, version, author, declared capabilities, signature status, update source and the SHA-256 digest. 4. Click **Install package**. The package is installed for the whole application but **not enabled** anywhere yet. Installing never runs `npm install`, build scripts or the plugin itself. ## Enable a plugin in a workspace Plugins run per workspace. In **Installed plugins**, choose **Enable for workspace**, read the high-trust notice and confirm with **I trust this plugin, enable**. Enabled plugins run inside the GhostBoard window with full access to workspace data, local files, the network and the page. The **Declared capabilities** list is informational, not a restriction. Switching to another workspace does not carry the enablement across; each workspace records its own choice, and a plugin's stored data is kept separately per workspace. ## Disable, update, roll back - **Disable for workspace** removes the plugin's commands, panels, subscriptions and tasks in that workspace. Its stored data, canvas cards and imported Notes stay. - **Check for update** is available for signed plugins that declared an update source. GhostBoard downloads the release document, picks the newest version compatible with your GhostBoard, verifies the SHA-256 and the author signature, then shows the same review step as a fresh install. An update signed with a different key or pointing at a different source is refused with "author key changed". - **Roll back code to …** reinstalls the previous version's verified files. The plugin remains disabled until you review and enable it again. - **Restore pre-migration backup** appears when a plugin's canvas card data was migrated to a newer `dataVersion`. Restoring replaces migrated card data with the backup and keeps the plugin disabled. ## Safe mode If GhostBoard fails to finish starting with plugins enabled, the next launch skips third-party code automatically and shows **Plugin safe mode is active** in Settings → Plugins. You can also request it with the `--safe-mode` argument or `GHOSTBOARD_SAFE_MODE=1`. In safe mode you can still disable or remove plugins and restore data; choose **Try enabled plugins on next launch** when you are done. ## Uninstall Removing a plugin deletes only its code. Cards created by a custom canvas type stay readable through their saved summaries, plugin key-value data stays, and Notes created by an importer are ordinary Notes. Permanent data deletion is a separate, explicit action that shows its scope. --- # Build your first plugin in 15 minutes URL: https://ghostboard.21blog.com/docs/start/first-plugin/ Goal: a working plugin with one command and one panel, running in GhostBoard, tested and packaged. Time checkpoints assume GhostBoard is already installed and you have Bun. > **Current preview:** npm publication is deferred. Use the source checkout workflow below now; the registry command is for the later published release. ```sh git clone https://github.com/laogui/electrobun-starter-tanstack.git cd electrobun-starter-tanstack bun install bun run plugin:create ../hello-plugin cd ../hello-plugin bun install ``` ## 1. Scaffold (2 min) ```sh bunx create-ghostboard-plugin hello-plugin cd hello-plugin bun install ``` `bun install` only fetches `@ghostboard/sdk`, which provides TypeScript declarations, the single-file `AI_REFERENCE.md`, a fake host for tests and the `ghostboard-plugin` packer. None of it ships to end users. You now have: ```text hello-plugin/ ├── manifest.json # id, version, engines.ghostboard, entry point ├── app.js # definePlugin({ setup }) with a command and a panel ├── ui.js # editable Button/Input/Surface helpers using --gb-* theme tokens ├── app.test.js # runs against @ghostboard/sdk/testing ├── AGENTS.md # points coding agents at the SDK reference └── package.json # bun test, bun run package ``` Open `manifest.json` and change `id` to something unique in reverse-DNS-ish form, for example `yourname.hello`. IDs starting with `ghostboard.` are reserved. ## 2. Run it in GhostBoard (3 min) 1. Open **Settings → Plugins → Connect development directory…** and choose `hello-plugin/`. 2. Review the directory identity and click **Connect directory**, then **Enable for workspace** and confirm the high-trust notice. 3. Open the plugin menu and run **Open Hello panel**. The panel appears with your workspace ID. Connecting only registers the directory; code is loaded when you enable it. ## 3. Edit with live reload (3 min) Change the panel text in `app.js`: ```js import { definePlugin } from "@ghostboard/sdk"; export default definePlugin({ setup(app) { app.commands.register({ id: "open-hello", title: "Open Hello panel", shortcut: "Mod+Shift+H", run: () => app.panels.open("hello"), }); app.panels.register({ id: "hello", title: "Hello", mount(element) { const heading = document.createElement("h2"); heading.textContent = `Hello from ${app.pluginId} in ${app.workspaceId}`; element.append(heading); return () => heading.remove(); }, }); }, }); ``` Save. The enabled plugin reloads automatically: the old registrations are cleaned up and the new ones published. If your new code throws during import or `setup`, the previous version keeps running and the plugin menu shows the error with the file and line. Anything you registered is cleaned up in reverse order when the plugin reloads or is disabled; DOM you create outside the panel's mount element is not. ## 4. Test without GhostBoard (2 min) ```sh bun test ``` `app.test.js` creates a fake host, runs `setup`, checks the registered command and panel IDs and triggers the command. The fake host does not reproduce layout, persistence, native dialogs or platform behavior; it is for logic. ## 5. Package (2 min) ```sh bun run package ``` This writes `dist/-.ghostboard-plugin` containing only `manifest.json`, `app.js` and `ui.js` — the files listed as `--include` in the `package` script. The archive is deterministic (same bytes in, same hash out) and never executes your code. Install it through **Settings → Plugins → Choose package…** to see the end-user flow. ## 6. Next steps (3 min of reading) - Add a React panel: [Panels](/docs/guides/panels/), or scaffold with `--react`. - Read and change Notes: [Notes, storage and events](/docs/guides/content-and-storage/). - Ship updates: [Signing and updates](/docs/distribution/signing-and-updates/). - Let a coding agent continue: `AGENTS.md` in your project points it to `node_modules/@ghostboard/sdk/AI_REFERENCE.md`, the same text as the [SDK reference](/docs/reference/sdk/). --- # Commands URL: https://ghostboard.21blog.com/docs/guides/commands/ A command is the smallest plugin: a `manifest.json` and one JavaScript file. ```js import { definePlugin } from "@ghostboard/sdk"; export default definePlugin({ setup(app) { app.commands.register({ id: "tidy-titles", title: "Tidy selected titles", shortcut: "Mod+Shift+T", enabled: () => !app.signal.aborted, run: () => tidyTitles(app), }); }, }); ``` ## Rules of thumb - **IDs are plugin-local kebab-case.** The host namespaces them by plugin ID, so two plugins can both have `open-summary`. - **Host shortcuts win.** Text editing and built-in shortcuts take priority over plugin shortcuts. IME composition, key repeat and keys the focused context already owns never reach your command, including canvas keys bound to a selected core node — `Tab`, `Enter` and `F2` act on a selected or in-edit Topic, for example. - **Shortcuts are a convenience, not the entry point.** Prefer a `Mod`-based combination so you do not compete with the host, and always keep a menu entry for the same command. - **One function per behavior.** Put the real work in a function and call it from the command, a panel button and tests. - **Registration is staged.** Everything registered in `setup` is published only after `setup` succeeds. A failed reload leaves the old command working. - **Clean up owned resources.** `app.commands.register` returns a disposer, and you can add arbitrary cleanup with `app.onCleanup`. Cleanup runs in reverse order, once, when the plugin reloads, is disabled or the workspace closes. - **Respect `app.signal`.** Pass it to `fetch`, storage and content calls so long operations stop when the activation retires. ## A command that edits Notes ```js async function tidyTitles(app) { const selected = await app.selection.get({ signal: app.signal }); const updates = selected .filter((note) => note.revision !== null) .map((note) => ({ id: note.id, expectedRevision: note.revision, changes: { title: note.title.trim().replace(/\s+/g, " ") }, })); if (updates.length === 0) return; await app.content.updateMany(updates, { label: "Tidy titles", operationId: `tidy-${Date.now()}`, signal: app.signal, }); } ``` `app.selection.get()` returns only the Notes behind the selection, so a selection that also contains Topics, folders or images simply yields fewer items than the user selected. Use `getPlacements()` when you need to tell the user what was skipped. Updates are compare-and-swap on `expectedRevision`: if the user edited a Note in between, that item fails visibly instead of overwriting. The batch is recorded in the workspace history, so the user can undo it with the normal Undo shortcut while the canvas view is active, and your plugin can call `app.content.undo(batchId)` later. See [Notes, storage and events](/docs/guides/content-and-storage/). ## Testing ```js import { createFakeHost } from "@ghostboard/sdk/testing"; import plugin from "./app.js"; const fake = createFakeHost({ pluginId: "yourname.tidy", workspaceId: "w1" }); await plugin.setup(fake.app); await fake.harness.behavior.runCommand("tidy-titles"); await fake.harness.lifecycle.dispose(); ``` --- # Panels URL: https://ghostboard.21blog.com/docs/guides/panels/ A panel is a view the user opens from a command or the plugin menu. It is either a **DOM mount function** or a **React component**, never both. ## DOM panel (no build step) ```js app.panels.register({ id: "summary", title: "Summary", mount(element) { const section = document.createElement("section"); section.style.color = "var(--gb-foreground)"; section.style.background = "var(--gb-surface)"; section.textContent = `Workspace: ${app.workspaceId}`; element.append(section); return () => section.remove(); }, }); app.panels.open("summary"); ``` The disposer returned from `mount` runs when the panel closes or the plugin retires. Keep every DOM and style change inside `element`; the rest of the page is host UI that may be re-rendered at any time. ## React panel Scaffold with `bunx create-ghostboard-plugin my-panel --react`. The template uses Vite with `react`, `react-dom`, `react-dom/client`, `react/jsx-runtime`, `@ghostboard/sdk` and `@ghostboard/sdk/react` marked **external**, so your bundle uses the host's React 19.2 instance. Bundling your own React breaks hooks. ```tsx import { useSelectionState } from "@ghostboard/sdk/react"; function SelectionPanel() { const selection = useSelectionState(); if (selection.status === "loading") return

Loading…

; if (selection.status === "error") return

{selection.error?.message}

; return

{selection.items.length} selected Notes

; } app.panels.register({ id: "selection", title: "Selection", component: SelectionPanel, }); ``` The host creates and owns an independent React root per panel and wraps it in the SDK's selection provider, so `useSelection`, `useSelectionState` and `useSelectedPlacements` work without any context setup. An exception thrown by your component only breaks that panel's root; the host UI and your other contributions keep running. For projects whose WebView lacks import-map support, the React template also ships `vite.shim.config.ts`, which rewrites the six shared modules to adapters over `globalThis.__ghostboardPluginRuntime`. The default build is the recommended path. ## Theming without host components Host components are not part of the SDK. The templates vendor small `Button`, `Input` and `Surface` helpers (`ui.js` or `src/components/ui.tsx`) that you own and can edit. They use only the public CSS variables `--gb-background`, `--gb-surface`, `--gb-foreground`, `--gb-muted`, `--gb-muted-foreground`, `--gb-border`, `--gb-accent`, `--gb-accent-soft`, `--gb-accent-foreground`, `--gb-danger`, `--gb-focus-ring`, `--gb-radius`, `--gb-shadow`, `--gb-font-sans` and `--gb-font-mono`, so they follow light and dark mode automatically. That list is frozen for the current host major. Do not import host-private modules or copy private class names. ## Lifecycle details - Closing a panel disposes that view only; the plugin's commands stay available. - A successful reload closes old panels and publishes the new registrations. A failed reload keeps the open panel and its state. - Long-lived work started from a panel should observe `app.signal` or use `app.tasks.schedule` so it stops when the workspace deactivates. - Track any `Worker` you create with `app.workers.track(worker)` so retirement terminates it. ## Testing React panels `@ghostboard/sdk/testing/react` exports `renderPanel(definition, { context })`, which renders a component panel with Testing Library and fake SDK hooks. Use it for component logic; use a packaged GhostBoard build for layout and real selection behavior. --- # Custom canvas types URL: https://ghostboard.21blog.com/docs/guides/canvas-types/ A custom canvas type is a card the user can place on the canvas. Your plugin owns the component and the state shape; GhostBoard owns persistence, revisions, search indexing, preview and the missing-plugin fallback. ## Registered types and core node types `registerType` always creates an **extension** node. It is not a way to define, replace or impersonate a core node type that GhostBoard ships itself — cards, stickies, images, folders, groups and Topics are host-owned. The distinction matters most for **Topic**, GhostBoard's lightweight mind-map node. A Topic keeps its title inline in the canvas manifest, and its parent/child hierarchy lives entirely on host-managed tree edges that the host also lays out. There is no SDK call to read a Topic title, create a subtopic, reparent a branch or influence its layout, and no event that reports topic structure changes. Use `registerType` for your own versioned instance state; if you need hierarchy inside your card, model it in your own state, not on host trees. ## Minimal counter card ```js import { createElement } from "react"; function CounterCard({ context, state, updateState }) { return createElement( "button", { type: "button", disabled: context.readOnly, onClick: () => updateState({ count: state.count + 1 }), }, `Count: ${state.count}`, ); } app.canvas.registerType({ id: "counter", title: "Counter", dataVersion: 1, component: CounterCard, createInitialState: () => ({ count: 0 }), validateState: (state) => Boolean( state && typeof state === "object" && !Array.isArray(state) && typeof state.count === "number", ), summarize: (state) => ({ title: `Counter: ${state.count}`, plainText: `Counter value ${state.count}`, }), }); ``` The persisted type name is `/`. `createElement` avoids a build step; the `--react` and `--combined` templates show JSX and a richer scorecard. ## Contract | Member | Purpose | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `dataVersion` | Integer version of your state shape. Increase it when the shape changes. | | `createInitialState()` | State for a newly placed card. | | `validateState(state)` | Type guard. Persisted state is untrusted input; reject anything unexpected. | | `migrateState(state, fromVersion)` | Converts old state to the current shape. Required when you raise `dataVersion`. | | `summarize(state)` | `title` and `plainText` used for search, accessibility, preview, export and when the plugin is missing or disabled. | | `component` / `previewComponent` | Rendered inside the card and (optionally) in compact previews. | | `surface` | Default, minimum and maximum size, `resizable`, and `openBehavior: 'detail'` to get a focused view. | `updateState(next)` commits a new revision and returns it. Pass `{ history: 'ephemeral' }` only for transient interaction frames (dragging a slider) and finish with a recorded update so undo has a sensible step. ## What happens when the plugin is missing Cards keep their state, summary and search text when the plugin is disabled or uninstalled. Users see the summary placeholder; nothing is deleted. Re-enabling the plugin restores the live component. ## Migrations and recovery When a card with an older `dataVersion` is loaded, GhostBoard first writes a **preimage** (a host-owned backup of the old state), then calls `migrateState` and saves the result with a new revision. If the backup cannot be written, the migration does not run. Downgrading the plugin never overwrites data with an older shape, and a migration that hits a revision conflict leaves live data untouched. Users can restore the most recent preimage from Settings → Plugins (**Restore pre-migration backup**). Restoring disables the plugin and keeps a guard copy of the current data. Design migrations to be pure functions of the old state and keep `validateState` strict so a bad migration fails loudly instead of persisting garbage. Only canvas card state has this backup contract. Plugin key-value storage does not; version your own values there. ## Testing `createFakeHost` exposes registered types through `harness.inspection.canvasTypes`. Unit-test `validateState`, `migrateState` and `summarize` directly; verify rendering and persistence in a packaged GhostBoard build. --- # CSV importer URL: https://ghostboard.21blog.com/docs/guides/csv-importer/ An importer parses a file into Note previews. GhostBoard owns everything around it: the native file picker, field mapping, the immutable preview, confirmation, destination, retries, rollback and cleanup of the file handle. Your code never writes workspace content directly. Scaffold with `bunx create-ghostboard-plugin my-importer --importer`. ## Register ```js app.importers.register({ id: "notes-csv", title: "Import Notes from CSV", accept: [{ description: "CSV files", extensions: [".csv"] }], async inspectFields(file, { signal }) { const text = await app.files.readText(file, { signal }); const fields = parseHeader(text); const find = (name) => fields.find((f) => f.toLowerCase() === name) ?? null; return { fields, suggestedMapping: { title: find("title"), text: find("text"), color: find("color"), }, }; }, async parse(file, { mapping, signal }) { const text = await app.files.readText(file, { signal }); return parseCsvToNotes(text, mapping); // { notes, warnings } or { segments, warnings } }, }); ``` ## The user's flow 1. The user picks **Import Notes from CSV** in the Plugin Workbench. If several importers accept the same extension, the host asks which one to use. 2. The host opens the native file picker and hands your importer an opaque, read-only file handle bound to this plugin, workspace, activation and process. You get `name`, `size`, `mimeType` and `lastModified`, never a path. 3. If you implemented `inspectFields`, the host shows a mapping UI for the fixed Note surface: required `title`, optional `text` and `color`. One source field cannot feed two targets; at most 100 unique fields are offered. 4. `parse` returns Notes and warnings. The host shows an immutable preview with counts, titles, body excerpts, colors and warnings. Nothing has been written yet; cancelling here writes nothing. 5. On confirm, the host creates the Notes as **detached** Notes (in the library, not on a canvas) in one atomic 1–100 item transaction with a stable operation ID. For a single batch the user may also choose to place the new Notes on the open canvas in a grid or column; creation and placement are separate operations, so a placement failure leaves the Notes created and retryable. 6. The result is recorded with a batch ID. The user can undo it from the plugin batch history; disabling or uninstalling the importer keeps the Notes. ## More than 100 rows Return explicit segments instead of one list: ```js return { segments: chunk(notes, 100).map((notes) => ({ notes })), warnings, }; ``` Allowed: 2–50 segments of 1–100 Notes each (101–5,000 Notes total). The host commits segments in order, shows the completed range, deduplicates retries by segment operation ID and resumes from the failed segment after a lost response. Rollback runs completed ranges in reverse; a range that the user has since edited or referenced is kept whole while the others are removed. Segmented imports always stay detached. ## Limits and rules - `readText` is strict UTF-8 and defaults to 1 MiB (`maxBytes` up to 16 MiB); use `readBytes` or `stream` for binary or large files. - The `text` you return is plain text split into paragraphs, not Markdown. - Do not write workspace content, keep handles, or start background work in `inspectFields`/`parse`. Cancel promptly when `signal` aborts. - Handles are released when the session ends, the plugin reloads or is disabled, or the workspace switches. ## Testing ```js const fake = createFakeHost({ pluginId: "yourname.csv", workspaceId: "w1" }); await plugin.setup(fake.app); const file = fake.harness.files.addText("notes.csv", "title,text\nOne,Body", { mimeType: "text/csv", }); const fields = await fake.harness.behavior.inspectImporterFields( "notes-csv", file, ); const preview = await fake.harness.behavior.parseImporter("notes-csv", file, { mapping: fields.suggestedMapping, }); ``` --- # Notes, storage and events URL: https://ghostboard.21blog.com/docs/guides/content-and-storage/ ## Note snapshots `app.content` works on persisted Note projections, not live editor blocks: ```ts interface NoteContentSnapshot { id: string; kind: "note"; source: "sidecar" | "markdown"; revision: number | null; // null = legacy Markdown, read-only here title: string; color: string; text: string; // plain text, paragraphs separated by newlines } ``` Read with `app.content.get(id)`, `getMany(ids)` (up to 500) and `query({ text, after, limit })`, which pages with `nextCursor`. ## Create, update, undo ```js const created = await app.content.createMany( [ { title: "Question", text: "What changed?\nWhy?" }, { title: "Follow-up", color: "#bfdbfe" }, ], { label: "Create research notes", operationId: "research-page-17" }, ); const result = await app.content.updateMany( [{ id, expectedRevision: revision, changes: { title: "Renamed" } }], { label: "Rename note", operationId: "rename-42" }, ); await app.content.undo(result.operationId); ``` - Batches contain **1–100 items** and are atomic: all or nothing. - `expectedRevision` is compare-and-swap. A Note edited in the meantime fails that batch instead of overwriting; the user's open editor shows a conflict view with "keep mine" / "use saved" choices. - `operationId` makes retries safe: retrying an uncertain request with the same ID and identical input returns the original result without duplicating work. Different input with the same ID is rejected. - Every batch is persisted with a batch ID. `undo`/`redo` work across restarts. Undo refuses to delete created Notes that were since edited, placed on a canvas or referenced, so nothing disappears under the user. - Committed batches also join the workspace's ordered history with native canvas changes; the normal Undo shortcut reverts them in order while the canvas view is active. Focused editors keep their own local history. - `text` is plain text. It is not Markdown and not a lossless round trip for rich content. New Notes are **detached**: they appear in the library and search but are not placed on a canvas unless the user places them (importers offer single-batch placement). ## Selection `app.selection.get()` returns the unique persisted Notes behind the current canvas selection; `getPlacements()` exposes placement IDs including widgets and extensions; `getSnapshot()`/`subscribe()` give a cached, lifecycle-bound snapshot with `loading`/`ready`/`error` states. Selection covers Notes explicitly selected in the active canvas; containers are not expanded. ### Placements without content Core node types that have no backing content record — folders, groups, images and Topics — appear in `getPlacements()` with `content: null`, and never in `get()` or `getSnapshot().items`. A mixed selection is therefore normal: ```js app.selection.getPlacements().items; // [ // { id: "topic-1", content: null }, // { id: "note-placement", content: { id: "note-1", kind: "note" } }, // ] ``` That array is the placement view, not the return value of `app.selection.get()`. Read the Notes with `get()` and treat a null-content placement as "selected but not readable through this API" instead of silently dropping it — a command that reports "2 selected, 1 note updated" is clearer to the user than one that pretends the Topic was not there. Two consequences worth designing for: - **Containers and Topics do not expand.** Selecting a folder or group does not select its contents, and selecting a Topic does not select its subtree: one selected Topic is exactly one placement, however many descendants it has. There is no SDK call that walks a Topic branch. - **`app.content` is Notes only.** Widgets, extension instances and Topics are never returned as `NoteContentSnapshot`, and `app.content.updateMany()` cannot be pointed at them. Editing a Topic does not emit a `content.changed` event describing a Note; only `selection.changed` still applies when selection membership or the active view changes. ## Storage ```js const count = (await app.storage.workspace.get("open-count")) ?? 0; await app.storage.workspace.set("open-count", Number(count) + 1); const keys = await app.storage.app.list("prefs:"); ``` - `storage.app` is shared by this plugin across workspaces; `storage.workspace` is per plugin and workspace and never leaks between workspaces. - Values are JSON only. Data survives disable, reload and uninstall; only an explicit permanent-delete removes it. - There is **no versioned migration API for storage**. Store a version inside your values and handle old shapes yourself. ## Events and tasks ```js app.events.on( "content.changed", ({ ids, operation, noteChanges }) => { /* ... */ }, { signal: app.signal }, ); app.events.on("selection.changed", ({ placementIds, viewId }) => { /* ... */ }); app.events.on("workspace.activated", ({ workspaceId }) => { /* ... */ }); app.tasks.schedule("refresh", { every: 60_000 }, () => refresh(app)); app.tasks.schedule("reminder", { at: new Date(Date.now() + 3_600_000) }, () => notify(app), ); ``` Events fire for host, plugin and editor changes in the current workspace. Tasks run only while the workspace activation is alive; missed ticks are not replayed, and everything stops on disable or reload. ## Files (outside importers) `app.files.pick`, `pickDirectory`, `readText`, `readBytes`, `stream`, `list` and `write` are available to any plugin. Handles are opaque, bound to the current activation and process, and cannot be persisted. Writes go to a host temporary file and are renamed into a directory the user selected; limits are 16 MiB collected reads, 256 MiB streams and writes, eight concurrent writes. Release handles with `app.files.release(...)` when a workflow finishes. --- # SDK reference URL: https://ghostboard.21blog.com/docs/reference/sdk/ Source version: @ghostboard/sdk 0.1.1 > This page is a verbatim copy of `AI_REFERENCE.md` from `@ghostboard/sdk` 0.1.1. The same text is installed with the package at `node_modules/@ghostboard/sdk/AI_REFERENCE.md` and included in [llms-full.txt](/docs/llms-full.txt). This self-contained reference is for humans and coding agents maintaining a GhostBoard trusted plugin without reading GhostBoard's private source tree. The adjacent `index.d.ts`, `react.d.ts`, and `testing.d.ts` files remain the exact type source of truth. ## Runtime and trust model - A plugin is high-trust JavaScript explicitly installed and enabled by the user. It shares the renderer JavaScript realm and can use browser APIs. - The SDK manages registrations, lifecycle, workspace identity, host services, and recoverable content operations. It is not a permission sandbox. - Keep DOM and CSS changes inside panel/card mount points. Host UI outside those points is private and may be replaced on any render. - `capabilities` in the manifest are user-facing claims, not enforced grants. - The host supplies `@ghostboard/sdk`, `@ghostboard/sdk/react`, `react`, `react-dom`, `react-dom/client`, and `react/jsx-runtime`. Never bundle a second React instance. - There is no supported Bun/backend plugin entry. Use the host services, browser workers, and ordinary browser APIs. ## Runtime package and manifest An installable runtime directory has `manifest.json` at its root and at least one JavaScript entry. Relative modules and static assets may sit beside it. ```text runtime/ ├── manifest.json ├── app.js ├── csv.js └── assets/ └── icon.svg ``` Minimal manifest: ```json { "manifestVersion": 2, "id": "example.notes", "name": "Example Notes", "version": "0.1.0", "engines": { "ghostboard": ">=1.0.0 <2.0.0" }, "entrypoints": { "app": "app.js" }, "capabilities": [] } ``` The schema is closed. Supported optional fields are `author`, `description`, `publicKey`, and `update: { url }`. IDs must be third-party IDs; `ghostboard.*` is reserved. App entries must be safe package-relative `.js` or `.mjs` paths. Current capability names are `network`, `files`, and `clipboard`. `publicKey` must be the exact `ed25519-spki:...` value emitted by the packer's key command. Unsigned local packages omit it. ## Definition and activation lifecycle Every entry default-exports `definePlugin({ setup })`: ```js import { definePlugin } from '@ghostboard/sdk'; export default definePlugin({ setup(app) { // Register contributions and owned resources here. return () => { // Optional final cleanup. }; }, }); ``` ```ts type Disposer = () => void | Promise; interface PluginDefinition { setup(app: PluginContext): void | Disposer | Promise; } declare function definePlugin(definition: T): T; interface PluginContext { readonly pluginId: string; readonly workspaceId: string; readonly generation: number; readonly signal: AbortSignal; onCleanup(disposer: Disposer): Disposer; // commands, panels, canvas, content, selection, importers, files, // storage, events, tasks, and workers are described below. } ``` Registrations made during `setup` are staged. A successful reload aborts and cleans the old generation before publishing the candidate. A failed import or setup cleans only the candidate and leaves the old generation running. `app.signal` aborts when its own activation retires or fails. Cleanup runs in reverse registration order and at most once. Register resources during setup, respect abort signals, and avoid irreversible side effects while a candidate is preparing. ## Commands ```ts interface CommandDefinition { id: string; title: string; shortcut?: string; // for example Mod+Shift+J enabled?: () => boolean; run: () => void | Promise; } app.commands.register(command: CommandDefinition): Disposer; ``` ```js app.commands.register({ id: 'open-summary', title: 'Open summary', shortcut: 'Mod+Shift+J', enabled: () => !app.signal.aborted, run: () => app.panels.open('summary'), }); ``` Use plugin-local kebab-case IDs. Host shortcuts and text editing take priority: reserved host shortcuts, IME composition, key repeat, and keys the focused context already owns never reach a plugin command. That includes the canvas keys that act on a selected core node — for example `Tab`, `Enter` and `F2` while a Topic is selected or its title is being edited — so do not bind a plugin command to a bare key and expect it to fire everywhere. Put business behavior in one function and call it from every entry point. ## Panels A panel uses either a React component or a DOM mount function, never both. ```ts type PanelDefinition = { id: string; title: string; } & ( | { component: React.ComponentType; mount?: never } | { component?: never; mount(element: HTMLElement): void | Disposer } ); app.panels.register(panel: PanelDefinition): Disposer; app.panels.open(id: string): void; app.panels.close(id: string): void; ``` ```js app.panels.register({ id: 'summary', title: 'Summary', mount(element) { const section = document.createElement('section'); section.textContent = `Workspace: ${app.workspaceId}`; element.append(section); return () => section.remove(); }, }); ``` ```jsx function SummaryPanel() { return
Ready
; } app.panels.register({ id: 'summary', title: 'Summary', component: SummaryPanel, }); ``` The host owns the React root. Closing a panel disposes that view but does not disable the plugin or remove its command. Use public `--gb-*` CSS variables or vendored components from the template; host components are not SDK APIs. ## Storage, events, tasks, cleanup, and workers Storage accepts JSON values only: ```ts type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; interface PluginStorage { get(key: string, options?: { signal?: AbortSignal }): Promise; set(key: string, value: JsonValue, options?: { signal?: AbortSignal }): Promise; delete(key: string, options?: { signal?: AbortSignal }): Promise; list(prefix?: string, options?: { signal?: AbortSignal }): Promise; } app.storage.app: PluginStorage; // this plugin across workspaces app.storage.workspace: PluginStorage; // this plugin + workspace ``` ```js const count = (await app.storage.workspace.get('open-count')) ?? 0; await app.storage.workspace.set('open-count', Number(count) + 1); ``` Typed events: ```ts type PluginEventMap = { 'content.changed': { ids: readonly string[]; kind: 'note' | 'widget' | 'extension'; operation: 'upsert' | 'delete'; workspaceId: string; noteChanges?: readonly NoteMetadataChange[]; }; 'selection.changed': { placementIds: readonly string[]; viewId: string | null; workspaceId: string; }; 'workspace.activated': { workspaceId: string }; }; app.events.on(event, listener, { signal? }): Disposer; ``` Tasks exist only while the workspace activation is running. Missed ticks are not replayed: ```ts type TaskSchedule = { every: number; at?: never } | { at: Date; every?: never }; app.tasks.schedule(id: string, schedule: TaskSchedule, run: () => void | Promise): Disposer; ``` Register arbitrary owned resources with `app.onCleanup`. Track a browser `Worker` with `app.workers.track(worker)` so retirement terminates it. ## Note content Content snapshots are persisted Note projections, not lossless editor blocks. `app.content` covers Notes only: widgets, extension instances and core host node types such as Topic are not exposed as content snapshots, and editing them does not emit a `content.changed` event describing a Note. ```ts interface NoteContentSnapshot { id: string; kind: 'note'; source: 'sidecar' | 'markdown'; revision: number | null; title: string; color: string; text: string; } interface NoteContentCreate { title: string; color?: string; text?: string; } ``` Reads: ```ts app.content.get(id, { signal? }): Promise; app.content.getMany(ids, { signal? }): Promise<(NoteContentSnapshot | null)[]>; // max 500 IDs app.content.query({ text?, after?, limit? }, { signal? }): Promise<{ items: NoteContentSnapshot[]; nextCursor: string | null }>; ``` Create detached Notes in one 1–100 item transaction: ```js const created = await app.content.createMany( [ { title: 'Question', text: 'What changed?\nWhy?' }, { title: 'Follow-up', color: '#bfdbfe' }, ], { label: 'Create research notes', operationId: 'research-page-17' } ); ``` Update with compare-and-swap revisions: ```ts interface NoteContentUpdate { id: string; expectedRevision: number; changes: { title?: string; color?: string; text?: string }; } app.content.updateMany(updates, { label, operationId?, signal? }): Promise; ``` `text` is literal plain text split into paragraphs. It is not Markdown and is not a lossless round trip for rich content. Legacy Markdown snapshots have a `null` revision and are read-only through this API. Mutation results contain a durable `operationId`. Retry an uncertain request with the same ID and byte-equivalent input to receive the original result without duplicating work. Reusing the ID with different input fails. Pass the original batch ID to `undo` or `redo`; conflicts fail safely rather than overwriting later edits or references. ```ts interface ContentBatchResult { changed: boolean; items: Array<{ id: string; title: string; color: string; revision: number }>; operationId: string; removedIds?: string[]; } app.content.undo(batchId, { operationId?, signal? }): Promise; app.content.redo(batchId, { operationId?, signal? }): Promise; ``` ## Selection and React hooks ```ts interface PluginSelection { get(options?: { signal?: AbortSignal }): Promise; getPlacements(): { viewId: string | null; items: readonly SelectionPlacement[]; }; getSnapshot(): SelectionSnapshot; subscribe(listener: () => void): () => void; } ``` `get()` captures selected placement membership first, then reads unique persisted Notes. `getPlacements()` includes selected widgets/extensions and placement IDs. `getSnapshot()` distinguishes `loading`, `ready`, and `error`. Core host node types without a backing content record — folders, groups, images and Topics — appear in `getPlacements()` with `content: null` and never in `get()` or `getSnapshot().items`. Selecting a container does not expand its children, and selecting a Topic does not expand its subtree: one selected Topic is exactly one placement. A mixed selection is therefore normal: ```js app.selection.getPlacements().items; // [ // { id: 'topic-1', content: null }, // { id: 'note-placement', content: { id: 'note-1', kind: 'note' } }, // ] ``` Read the Notes with `get()` and treat null-content placements as "present but not readable through this API" rather than filtering them out silently. React panels can import: ```ts useSelection(): readonly NoteContentSnapshot[]; useSelectionState(): SelectionSnapshot; useSelectedPlacements(): PlacementSelectionSnapshot; SelectionProvider({ selection, children }): ReactElement; ``` ```jsx import { useSelectionState } from '@ghostboard/sdk/react'; function SelectionPanel() { const selection = useSelectionState(); if (selection.status === 'loading') return

Loading…

; if (selection.status === 'error') return

{selection.error?.message}

; return

{selection.items.length} selected Notes

; } ``` The host wraps registered React panels. Use `SelectionProvider` only for a root you create yourself or in standalone tests. ## Custom canvas types ```ts interface CanvasTypeDefinition { id: string; title: string; description?: string; dataVersion: number; component: React.ComponentType>; previewComponent?: React.ComponentType>; createInitialState(): TState; validateState(state: unknown): state is TState; migrateState?(state: JsonValue, fromVersion: number): TState; summarize(state: TState): CanvasTypePresentation; surface?: { defaultSize?: { width: number; height: number }; minSize?: { width: number; height: number }; maxSize?: { width: number; height: number }; openBehavior?: 'none' | 'detail'; resizable?: boolean; }; } app.canvas.registerType(definition): Disposer; ``` Minimal example without JSX: ```js import { createElement } from 'react'; function CounterCard({ context, state, updateState }) { return createElement( 'button', { disabled: context.readOnly, type: 'button', onClick: () => updateState({ count: state.count + 1 }), }, `Count: ${state.count}` ); } app.canvas.registerType({ id: 'counter', title: 'Counter', dataVersion: 1, component: CounterCard, createInitialState: () => ({ count: 0 }), validateState: (state) => Boolean( state && typeof state === 'object' && !Array.isArray(state) && typeof state.count === 'number' ), summarize: (state) => ({ title: `Counter: ${state.count}`, plainText: `Counter value ${state.count}`, }), }); ``` The persisted type is `${pluginId}/${id}`. Registered types are always `extension` nodes: a plugin cannot register, replace or impersonate a core host type such as a card, sticky, Topic, folder or group. Topic hierarchy in particular is host-owned — it lives on host-managed tree edges, and there is no SDK call to read a Topic title, create a subtopic, reparent a branch or drive its layout, and no event that reports topic structure changes. Use `registerType` for your own versioned instance state, not to model host trees. `summarize` supplies search, accessibility, preview, export, and missing-plugin fallback text. Treat state as untrusted input; validate it and provide migrations when increasing `dataVersion`. `updateState` returns the committed revision. Use `history: 'ephemeral'` only for transient interaction frames and finish with a recorded update. GhostBoard writes a host-owned preimage before a `dataVersion` increase. The plugin settings recovery action can restore the latest pre-migration instance with a new monotonic revision after retaining a guard copy of current data. Code rollback and data restore are separate and both leave the plugin disabled. This recovery contract applies to canvas instances; plugin KV storage does not yet have a versioned migration API. ## Files File and directory handles are opaque capabilities bound to the current plugin, workspace, activation, and app process. Do not persist handle IDs. ```ts interface PluginFileHandle { readonly id: string; readonly kind: 'file'; readonly name: string; readonly size: number; readonly mimeType: string; readonly lastModified: number; } interface PluginDirectoryHandle { readonly id: string; readonly kind: 'directory'; readonly name: string; } ``` Selection and release: ```ts app.files.pick({ accept: readonly { description: string; extensions: readonly string[] }[]; multiple?: boolean; // at most 16 files signal?: AbortSignal; }): Promise; app.files.pickDirectory({ signal? }): Promise; app.files.release(...handles): void; ``` Reads: ```ts app.files.readText(file, { start?, length?, maxBytes?, onProgress?, signal? }): Promise; // strict UTF-8, default 1 MiB, max 16 MiB collected app.files.readBytes(file, { start?, length?, maxBytes?, onProgress?, signal? }): Promise; // default max 16 MiB app.files.stream(file, { start?, length?, onProgress?, signal? }): ReadableStream; // sequential 1 MiB ranges, max 256 MiB ``` Directories and atomic writes: ```ts app.files.list(directory, { cursor?, limit?, signal? }): Promise<{ entries: readonly { name: string; kind: 'file' | 'directory' | 'symlink'; handle?: PluginFileHandle | PluginDirectoryHandle; }[]; nextCursor: string | null; }>; app.files.write(directory, relativePath, data, { overwrite?, onProgress?, signal? }): Promise; ``` `data` may be a string, `Blob`, `ArrayBuffer`, typed-array view, or `ReadableStream`. Writes are limited to 256 MiB, use a host temporary file followed by final rename, and stay below a directory the user selected. Symlinks are listed without a usable handle. Release handles as soon as the workflow finishes; activation retirement also releases them. ## Importers Importers parse a selected file into an immutable, host-owned Note preview. They do not commit content themselves. ```ts interface ImporterDefinition { id: string; title: string; accept: readonly PluginFileFilter[]; inspectFields?(file, { signal }): Promise<{ fields: readonly string[]; suggestedMapping?: Partial<{ title: string | null; text: string | null; color: string | null; }>; }>; parse(file, { mapping?, signal }): Promise< | { notes: readonly NoteContentCreate[]; warnings: readonly string[] } | { segments: readonly { notes: readonly NoteContentCreate[] }[]; warnings: readonly string[]; } >; } app.importers.register(definition): Disposer; ``` Mapped CSV example: ```js app.importers.register({ id: 'notes-csv', title: 'Import Notes from CSV', accept: [{ description: 'CSV files', extensions: ['.csv'] }], async inspectFields(file, { signal }) { const text = await app.files.readText(file, { signal }); const fields = parseHeader(text); const find = (name) => fields.find((field) => field.toLowerCase() === name) ?? null; return { fields, suggestedMapping: { title: find('title'), text: find('text'), color: find('color'), }, }; }, async parse(file, { mapping, signal }) { const text = await app.files.readText(file, { signal }); return parseCsvToNotes(text, mapping); }, }); ``` Field inspection is opt-in and limited to 100 unique fields. The host displays mapping to the complete bounded Note surface: required `title`, optional `text` and `color`. One source field cannot feed two targets. The single-batch form contains 1–100 Notes. For 101–5,000 Notes, return 2–50 explicit segments, each containing 1–100 Notes. The host commits segments in order with stable operation IDs and can resume an uncertain/failed range. Rollback runs completed ranges in reverse and preserves a whole range on later edit/reference conflict. Segmented imports stay detached. A single batch may be placed on a selected canvas after creation; content creation and canvas saving are separate operations, so a placement failure leaves the Notes detached and retryable. The host owns importer selection, source-handle lifetime, field mapping, preview, warnings, destination choice, confirmation, retry, and cancellation. Do not write workspace content during `inspectFields` or `parse`. ## Author tests `@ghostboard/sdk/testing` provides an in-memory fake: ```js import { createFakeHost } from '@ghostboard/sdk/testing'; import plugin from './app.js'; const fake = createFakeHost({ pluginId: 'example.notes', workspaceId: 'test-workspace', }); await plugin.setup(fake.app); const file = fake.harness.files.addText( 'notes.csv', 'title,text\nOne,Body', { mimeType: 'text/csv' } ); const preview = await fake.harness.behavior.parseImporter('notes-csv', file); await fake.harness.lifecycle.dispose(); ``` Useful harness surfaces: - `inspection.commands`, `panels`, `canvasTypes`, `importers`, `tasks`, `openPanels`, `generation`, `signal`, and `cleanupCount`. - `behavior.runCommand`, `runTask`, `inspectImporterFields`, `parseImporter`, `emit`, `setSelection`, and `abort`. - `files.addText`, `addBytes`, `addDirectory`, `queuePick`, and `queueDirectory`. - `lifecycle.reload(factory)` and `lifecycle.dispose()`. `@ghostboard/sdk/testing/react` exports `renderPanel(definition, { context })` for component panels. The fake does not reproduce host layout/CSS, disk persistence, real RPC/native dialogs, multi-plugin arbitration, installation, WebView behavior, or platform differences. Validate those in a packaged GhostBoard app. ## Packaging The SDK includes a deterministic, dependency-free package command exposed as the `ghostboard-plugin` bin. Projects created with `create-ghostboard-plugin` call it from their `package` script; run it directly with `bunx ghostboard-plugin` (or `bun node_modules/@ghostboard/sdk/package-plugin.js`) after `bun install`: ```sh ghostboard-plugin \ --output release/example.notes-0.1.0.ghostboard-plugin ``` By default every regular file below the runtime directory is included. The output must be outside that directory. For a no-build project whose author files share the runtime root, provide repeated explicit includes: ```sh ghostboard-plugin . \ --output dist/example.notes-0.1.0.ghostboard-plugin \ --include manifest.json \ --include app.js \ --include csv.js ``` The packer sorts paths, writes deterministic stored ZIP entries, refuses symlinks and escaping/unsafe paths, enforces the host's 256-file and 8 MiB uncompressed limits, checks manifest v2 and its app entry, and replaces the exact output atomically. It never executes plugin code or dependency lifecycle scripts. To establish an author identity for same-ID updates, generate a private key outside the runtime directory and source control: ```sh ghostboard-plugin --generate-key ../plugin-keys/example.notes.pem ``` Copy the printed `ed25519-spki:...` value into manifest `publicKey`, declare a stable HTTPS `update.url`, then package with: ```sh ghostboard-plugin \ --sign ../plugin-keys/example.notes.pem \ --output release/example.notes-0.1.0.ghostboard-plugin ``` The package contains strict `ghostboard-signature.json` schema v1. Its Ed25519 payload binds the plugin ID, version, normalized update URL, and a sorted tree SHA-256 that excludes only the envelope itself. The host separately records a full installed-tree digest including the envelope. A same-ID update must advance semver and retain the first verified public key and update URL. Key loss or rotation requires a new plugin ID. Unsigned packages remain usable for local experiments but cannot be updated in place. The archive may use `.ghostboard-plugin` or `.zip`. Final-user installation needs only this runtime archive; it must not require Bun, Node, `node_modules`, the `@ghostboard/sdk` package, source tests, or the host repository. ## Compatibility Reference version: `@ghostboard/sdk` `0.1.1` for GhostBoard hosts matching `>=1.0.0 <2.0.0`. `create-ghostboard-plugin` `0.1.0` generates that range. `0.1.1` adds no API: it documents how existing selection, command and canvas APIs behave next to host-owned core node types such as Topic. Templates pinning `^0.1.0` need no change. - `engines.ghostboard` must be exactly `>=a.b.c =1.0.0 <2.0.0" }, "entrypoints": { "app": "app.js" }, "capabilities": ["files"], "author": "Your Name", "description": "Imports notes from CSV files.", "publicKey": "ed25519-spki:MCowBQYDK2VwAyEA...", "update": { "url": "https://example.com/plugins/yourname.notes/releases.json" } } ``` The schema is **closed**: unknown fields are rejected so typos surface immediately. | Field | Required | Rules | | ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `manifestVersion` | yes | Must be `2`. `1` is the retired sandbox contract; such packages are refused with "Legacy contract; reinstall a v2 version" and their data is kept. | | `id` | yes | Third-party plugin ID, lowercase segments separated by dots (for example `yourname.notes`). `ghostboard.*` is reserved. The ID also namespaces canvas types, storage and commands. | | `name` | yes | User-visible name. | | `version` | yes | Semver. Same-ID updates must increase it. | | `engines.ghostboard` | yes | Exactly `>=a.b.c [--react|--importer|--combined] [--sdk ] ``` | Option | Template | | ------------ | --------------------------------------------------------------------------------------------------- | | _(none)_ | No-build JavaScript command + DOM panel (`manifest.json`, `app.js`, `ui.js`) | | `--react` | Vite + React panel; externalizes React and the SDK, includes an optional global-runtime shim config | | `--importer` | No-build mapped and segmented CSV importer | | `--combined` | Command, React panel, custom canvas type and CSV importer in one no-build package | The command refuses to overwrite an existing directory, copies the template, and prints the next steps. `--sdk ` rewrites the `@ghostboard/sdk` dependency written to `package.json` (for example `--sdk file:../ghostboard-sdk-0.1.0.tgz`); it exists for verifying templates against an unpublished SDK and is not needed by authors. Every generated project has `bun test` (fake-host author tests) and `bun run package`. The React template adds `bun run build`, `bun run dev` (watch build) and `bun run type-check`. ## `ghostboard-plugin` Installed with `@ghostboard/sdk`; the generated `package` scripts call it, or run `bunx ghostboard-plugin`. ```text ghostboard-plugin [options] --output Archive path. Defaults to -.ghostboard-plugin. --include Include one file or directory relative to the runtime directory. Repeat to package a selected set of runtime files. --sign Sign with an Ed25519 PKCS#8 PEM private key. ghostboard-plugin --generate-key ``` - Without `--include`, every regular file below the runtime directory is packaged (the React template packages `dist/`). With `--include`, only the listed files are packaged (the no-build templates list `manifest.json`, `app.js` and friends so tests and `node_modules` stay out). - The output must be outside the runtime directory and end with `.ghostboard-plugin` or `.zip`. - The archive is a stored ZIP written in sorted path order, so identical inputs produce an identical SHA-256. Symlinks, unsafe paths, more than 256 files or 8 MiB uncompressed are refused. - The packer validates manifest v2 and checks that the app entry is inside the archive. It never executes plugin code or lifecycle scripts. - `--generate-key` creates a private key with owner-only permissions, refuses to overwrite, and prints the `ed25519-spki:...` value for `publicKey`. Keep the key outside the runtime directory and out of source control; `--sign` refuses a key inside the runtime directory. - `--sign` requires `publicKey` in the manifest to match the key, and `publicKey` without `--sign` is refused, so a signed manifest can never ship unsigned by accident. See [Signing and updates](/docs/distribution/signing-and-updates/) for the release document that turns a signed package into an updatable one. --- # Packaging URL: https://ghostboard.21blog.com/docs/distribution/packaging/ ## What goes in the archive Only what GhostBoard needs at runtime: `manifest.json`, the app entry, relative modules and static assets. Author files (`package.json`, tests, `node_modules`, `AGENTS.md`, `README.md`, source `.ts`/`.tsx`) stay out. The generated `package` script already lists the right files: ```jsonc // no-build templates "package": "ghostboard-plugin . --output dist/yourname.hello-0.1.0.ghostboard-plugin --include manifest.json --include app.js --include ui.js" // React template: package the complete Vite output "package": "bun run build && ghostboard-plugin dist --output release/yourname.panel-0.1.0.ghostboard-plugin" ``` When you add a runtime module or asset to a no-build project, add another `--include`. When you bump `version` in `manifest.json`, rename the `--output` to match. ## Run it ```sh bun run package ``` The packer validates the manifest, checks the entry exists, sorts paths, writes a stored ZIP and replaces the output atomically. Running it twice on the same files yields byte-identical archives, so the SHA-256 shown in GhostBoard's review step is reproducible by anyone with your source. ## Install it like a user Open **Settings → Plugins → Choose package…**, select the archive, compare the digest with your build, install, enable and exercise every contribution. Then **Disable for workspace** and confirm nothing of yours lingers. This is the same path end users take and the only way to verify layout, persistence, native dialogs and reload behavior; author tests do not cover those. ## Distribute - **File**: share the `.ghostboard-plugin` archive. Users install it with **Choose package…**. - **URL**: host the archive on a public HTTPS origin and share the direct link. Users paste it into **Package URL** and click **Inspect link**. GhostBoard downloads with redirect, private-network, size and content-type checks and shows the same review step. Unsigned archives are fine for local use and sharing, but they **cannot be updated in place**: installing a newer unsigned archive with the same ID is treated as a new plugin. To publish updates, continue with [Signing and updates](/docs/distribution/signing-and-updates/). --- # Signing and updates URL: https://ghostboard.21blog.com/docs/distribution/signing-and-updates/ GhostBoard updates are **trust on first use**: the first install of a signed plugin pins the author's public key and the update URL. Every later update of that plugin ID must be signed with the same key, come from the same URL, and have a higher version. Nothing else — no account, domain purchase, certificate authority or review — is required. ## 1. Create an author key ```sh ghostboard-plugin --generate-key ../plugin-keys/yourname.notes.pem ``` Keep the key outside the runtime directory and out of source control. The command prints an `ed25519-spki:...` value; put it in `manifest.json`: ```json "publicKey": "ed25519-spki:MCowBQYDK2VwAyEA...", "update": { "url": "https://example.com/plugins/yourname.notes/releases.json" } ``` Declare `update.url` before your first signed release. Both values are pinned on install; changing either later means users must uninstall and install a new plugin ID. ## 2. Sign while packaging ```sh ghostboard-plugin dist \ --sign ../plugin-keys/yourname.notes.pem \ --output release/yourname.notes-0.1.0.ghostboard-plugin ``` The archive gains `ghostboard-signature.json` (schema v1). Its Ed25519 payload binds the plugin ID, version, normalized update URL and a sorted SHA-256 tree of every other file. GhostBoard verifies it before showing the review step. Tampering with any file, replacing the key, or removing the signature from a previously signed ID all fail closed. ## 3. Publish the release document Host the archive and a JSON release document at the pinned `update.url`, both on public HTTPS: ```json { "schema": "ghostboard-plugin-release/v2", "pluginId": "yourname.notes", "channel": "stable", "releases": [ { "version": "0.1.1", "ghostboard": ">=1.0.0 <2.0.0", "url": "https://example.com/plugins/yourname.notes/yourname.notes-0.1.1.ghostboard-plugin", "sha256": "9abca983b696e39fc0977f8dcfccaa7b1df093e81f400280fb7842b49d8bc8bf", "notes": "Fix CSV quoting." }, { "version": "0.1.0", "ghostboard": ">=1.0.0 <2.0.0", "url": "https://example.com/plugins/yourname.notes/yourname.notes-0.1.0.ghostboard-plugin", "sha256": "605aa4482daa0c68ef094132936a5efe665addb7c054aab60ebb25b44b73b34f" } ] } ``` Rules: `schema` exactly as shown; `channel` lowercase identifier; 1–50 releases with unique semver `version`s; each `ghostboard` is a `>=a.b.c { await fake?.harness.lifecycle.dispose(); fake = undefined; }); test("registers and runs the command", async () => { fake = createFakeHost({ pluginId: "yourname.hello", workspaceId: "test" }); await plugin.setup(fake.app); expect(fake.harness.inspection.commands.map(({ id }) => id)).toEqual([ "open-hello", ]); await fake.harness.behavior.runCommand("open-hello"); expect(fake.harness.inspection.openPanels).toEqual(["hello"]); }); ``` ## Harness surface | Area | Members | | ------------ | --------------------------------------------------------------------------------------------------------------- | | `inspection` | `commands`, `panels`, `canvasTypes`, `importers`, `tasks`, `openPanels`, `generation`, `signal`, `cleanupCount` | | `behavior` | `runCommand`, `runTask`, `inspectImporterFields`, `parseImporter`, `emit`, `setSelection`, `abort` | | `files` | `addText`, `addBytes`, `addDirectory`, `queuePick`, `queueDirectory` | | `lifecycle` | `reload(factory)` (atomic reload semantics: failed candidates leave the old generation) and `dispose()` | Storage, events and tasks are in-memory implementations of the same interfaces, so you can assert on `app.storage.workspace.get(...)` after running a command, emit `content.changed` and check the reaction, or advance an `at` task with `runTask`. ## React panels ```tsx import { renderPanel } from "@ghostboard/sdk/testing/react"; const { getByText } = renderPanel(definition, { context: fake.app }); ``` `renderPanel` renders a component panel with Testing Library and injects fake implementations of the SDK hooks, so `useSelectionState` reflects `harness.behavior.setSelection(...)`. ## What the fake does not do The fake host does not reproduce GhostBoard layout or CSS, disk persistence, real RPC and native dialogs, multi-plugin arbitration, package installation and signature verification, WebView behavior or platform differences. Before release, install your packaged archive in GhostBoard and run through every contribution, disable the plugin, and restart the app. --- # Example packages URL: https://ghostboard.21blog.com/docs/distribution/examples/ The same four templates that `create-ghostboard-plugin` generates are also published as signed, installable archives. They are the packages GhostBoard's own release verification installs from a public HTTPS origin, so you can use them to try URL installation, update checks and rollback before publishing your own. | Example | Contributes | Package URL | | ------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Hello (JavaScript) | Command + DOM panel | `https://ghostboard.21blog.com/plugins/examples/example.hello/0.1.0/example.hello-0.1.0.ghostboard-plugin` | | React Hello | Command + React panel | `https://ghostboard.21blog.com/plugins/examples/example.react-hello/0.1.0/example.react-hello-0.1.0.ghostboard-plugin` | | CSV Importer | Mapped and segmented importer | `https://ghostboard.21blog.com/plugins/examples/example.csv-importer/0.1.0/example.csv-importer-0.1.0.ghostboard-plugin` | | Combined | Command, panel, canvas type and importer | `https://ghostboard.21blog.com/plugins/examples/example.combined/0.1.0/example.combined-0.1.0.ghostboard-plugin` | Each example's release document is at `https://ghostboard.21blog.com/plugins/examples//releases.json`, and the manifest `update.url` points there. `example.hello` publishes both `0.1.0` and `0.1.1` so that installing the first URL and checking for updates exercises a real signed update. The SHA-256 values in `https://ghostboard.21blog.com/plugins/examples/index.json` identify the exact published signed archives. Changes to author keys, manifest metadata or build inputs produce different package digests. ## Try the update flow 1. Paste a package URL into **Settings → Plugins → Package URL** and click **Inspect link**, then install and enable it. 2. Click **Check for update**. When a newer example release exists, GhostBoard verifies it against the pinned example key and offers it for review; otherwise it reports that the installed version is current. 3. After an update, **Roll back code to …** restores the previous example version. The examples are signed with GhostBoard's example author key. That key only vouches for the example packages; do not reuse their plugin IDs for your own work. If a URL above returns 404, the example packages for that version have not been published yet. The source of every example is the corresponding template in the `create-ghostboard-plugin` package. --- # Compatibility policy URL: https://ghostboard.21blog.com/docs/policy/compatibility/ This is the public mirror of GhostBoard's compatibility policy. When the two disagree, the repository document wins and this page is updated. ## Current versions | Identity | Value | Role | | ------------------------------------- | ---------------- | ------------------------------------------------------------------------------ | | GhostBoard host | `1.0.0` | The value compared against `engines.ghostboard` | | Manifest version | `2` | The only manifest contract that loads | | `@ghostboard/sdk` | `0.1.1` | Author types, fake host, packer, single-file reference; also `runtime.version` | | `create-ghostboard-plugin` | `0.1.0` | Templates | | Template default `engines.ghostboard` | `>=1.0.0 <2.0.0` | Range new plugins declare | ## Host version range `engines.ghostboard` must be exactly `>=a.b.c = .0.0`; every current SDK API exists since host `1.0.0`, so templates declare `>=1.0.0 <2.0.0`. The host never widens or ignores your range. ## API promise 1. **Additive within a host major.** Everything in the [SDK reference](/docs/reference/sdk/) keeps its signature and semantics for host `1.x`. New fields, optional parameters and extension points may appear; existing plugins keep working. 2. **Breaking changes only with a new host major**, shipped together with a new SDK major, a new scaffold major, a new default `engines` range or manifest version, and "since host x.y" notes in the reference. Old plugins on a new major get `incompatible-host-api`, not a half-working load. 3. **Deprecation** is announced in the reference at least one host minor before removal in the next major, with a replacement. 4. **The host supplies the runtime.** `@ghostboard/sdk`, `@ghostboard/sdk/react` and React/ReactDOM 19.2.x come from the running GhostBoard through its import map. The npm package is author tooling only. If you use an API introduced in a newer host, raise your lower bound. 5. **React major upgrades are host breaking changes.** Never bundle your own React. ## SDK ↔ host table | `@ghostboard/sdk` | Hosts | `create-ghostboard-plugin` | Notes | | ----------------- | ---------------- | -------------------------- | ---------------------------------------- | | `0.1.x` | `>=1.0.0 <2.0.0` | `0.1.x` | Complete T1–T4 API; no Bun backend entry | `0.1.1` is a documentation-only patch: it adds no API and records how the existing selection, command and canvas APIs behave next to host-owned core node types such as Topic. Templates pinning `^0.1.0` already accept it, so no manifest or `engines` change is needed. SDK patch/minor releases fix types, the fake host or the reference, or add APIs in step with the host. SDK majors move with host majors. This documentation's reference page is generated from the pinned SDK version shown at its top. ## Data and update compatibility - **Manifest v1 packages** are refused with a notice and their data is kept. There is no dual runtime. - **Canvas card state**: migrated lazily when `dataVersion` increases, after a host-owned backup; downgrades, missing migrations and conflicts never overwrite data; users can restore the latest backup from Settings. - **Storage**: no versioned migration API; version your own values. - **Note transactions**: revisions, batch intents and receipts are host-owned; history file formats are host-managed and older hosts refuse newer history rather than partially restoring it. - **Signed updates**: unsigned packages cannot update in place; signed updates must advance semver and keep the pinned key and URL. Release documents use `ghostboard-plugin-release/v2` with per-release `ghostboard` ranges and SHA-256; the host picks the newest compatible release. - **Code rollback** keeps the current and one previous version and re-verifies before switching; rollback and data restore both leave the plugin disabled. - **Uninstall** removes code only. See [Supported platforms and limits](/docs/policy/support-and-limits/) for what is and is not verified. --- # Supported platforms and limits URL: https://ghostboard.21blog.com/docs/policy/support-and-limits/ GhostBoard only claims support for what has been exercised on a packaged build. Anything not listed here is unsupported. ## Platforms | Platform | Plugin support | Evidence | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | | macOS, Apple Silicon (arm64) | **Supported.** Loading, import map, host services, install, update, rollback and safe mode verified on packaged builds. | Release verification T0–T4, T5 | | macOS, Intel (x64) | **Pending verification.** Packaged builds exist; plugin verification will be published before support is claimed, stating whether it ran on Intel hardware or under Rosetta. | T5 platform record | | Windows x64 | **Not supported for plugins.** The app builds for Windows, but plugin loading, install and recovery have not been verified there. | — | | Linux | **Not supported.** No release matrix. | — | ## Install sources - **Local archive**: `.ghostboard-plugin` or `.zip`, chosen with the native file dialog. - **URL**: public `https://` only. Loopback and private-network addresses (including after DNS resolution or redirects), userinfo in URLs, non-ZIP responses, oversized downloads and HTML pages are refused. - **Development directory**: connected through the native directory dialog; loads on enable and reloads on change. - Native file and directory dialogs are macOS system dialogs; GhostBoard does not implement its own picker. ## Runtime and data limits | Area | Limit | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Package | 256 files, 8 MiB uncompressed, no symlinks | | Content batches | 1–100 items per transaction; `getMany` up to 500 IDs | | Importers | single batch 1–100 Notes; segmented 2–50 segments × 1–100 Notes (max 5,000); 100 unique inspectable fields | | Files | `readText` default 1 MiB (max 16 MiB), `readBytes` 16 MiB, `stream` 256 MiB, writes 256 MiB, 8 concurrent writes, 16 files per pick, 100 entries per directory page | | Release document | 1 MiB, 50 releases, 8 KiB notes | | Storage | JSON values; no size guarantee beyond the workspace disk | ## What GhostBoard does not promise - **No sandbox.** Plugins run in the app's renderer with full access. Declared capabilities are information for the user, not enforcement. Signatures prove continuity of authorship, not safety or identity. - **No rollback of arbitrary side effects.** Registered commands, panels, subscriptions, tasks and tracked workers are cleaned up on disable or reload. DOM changes outside mount points, direct `fetch` calls, files written through the SDK, and any other external effect are not undone; GhostBoard may ask the user to reload the window. - **Host DOM outside mount points is private** and may change on any render. Modifying it is unsupported. - **The fake host is not the app.** Author tests do not verify layout, persistence, dialogs, installation, signature verification or platform behavior. - **Old ES module closures** can remain in memory across repeated development reloads; reload the window if memory or duplicated behavior becomes a problem. - **Mixed undo history** (native canvas changes interleaved with plugin batches) is per workspace and view and does not survive a restart; explicit `app.content.undo/redo` by batch ID does. - **No API for host-owned canvas structure.** Core node types the host ships itself are not plugin-extensible. In particular there is no public way to read or write a Topic title, create a subtopic, reparent or reorder a branch, drive topic layout, or subscribe to topic structure changes; Topics surface only as `content: null` placements in [selection](/docs/guides/content-and-storage/#placements-without-content), and `app.content` stays [Notes-only](/docs/guides/content-and-storage/#note-snapshots). Reaching into host stores or DOM to work around this is unsupported and may break on any release. If that changes, it will ship as an additive API in a new SDK version with a "since host x.y" note in the [reference](/docs/reference/sdk/). - **No Bun or native backend entry**, no per-capability approval UI, no in-app plugin guide and no marketplace in this version. --- # Safe mode and recovery URL: https://ghostboard.21blog.com/docs/policy/safe-mode-and-recovery/ The design goal for failures is simple: a bad plugin, a bad package, a failed update or a broken migration must never take away the user's ability to manage plugins or recover data. ## Startup safe mode GhostBoard writes a startup marker before publishing third-party plugin code and clears it only after every enabled plugin has finished its first activation. If the previous launch never reached that point, the next launch **skips all third-party code** and Settings → Plugins shows **Plugin safe mode is active** with the reason: - the previous plugin startup did not finish; - the saved startup state could not be trusted (the damaged file is preserved and GhostBoard fails closed); - safe mode was requested explicitly with `--safe-mode` or `GHOSTBOARD_SAFE_MODE=1`. In safe mode you can disable or remove plugins, roll back code and restore data. **Try enabled plugins on next launch** ends safe mode for the following start; the current session stays safe. ## Failed reload during development A connected development directory reloads on change. If the new code fails to import or `setup` throws, the candidate is discarded: the previous activation keeps running with its commands, open panels and state, and the plugin menu shows the error pointing at your file. Successful reloads clean the old generation in reverse registration order exactly once before publishing the new one. ## Panel errors An exception in a panel component or mount function breaks only that panel's root. The host UI and the plugin's other contributions keep working; the panel shows a diagnostic and can be reopened. ## Bad or tampered packages - Archives are validated (paths, limits, manifest, entry, signature) before the review step and never executed during install. - Installed trees are re-verified against their canonical digest before every activation; a modified or corrupted tree is disabled with "Package files no longer pass validation" until reinstalled. - Unsigned in-place upgrades, signature mismatches, changed keys, changed update URLs, digest mismatches and non-advancing versions are all refused while the installed version stays intact. ## Failed or unwanted updates Installing an update keeps the previous version's verified files. **Roll back code to …** re-verifies and reactivates them; the plugin remains disabled until the user reviews and enables it again. Journals from the newer version cannot overwrite the rolled-back state on restart. ## Data migration recovery Before a custom canvas type migrates card state to a higher `dataVersion`, GhostBoard saves a preimage of the old state. If the backup fails, the migration does not run. **Restore pre-migration backup** in Settings → Plugins replaces migrated card data with the latest backup after saving a guard copy of the current data, and disables the plugin. Restoring and rolling back code are separate steps so users can choose either. ## What is not recoverable automatically Side effects outside the SDK — DOM edits outside mount points, direct network calls, files written to user-selected directories — are not transactional. Plugin key-value storage has no versioned migration, so plugins must tolerate their own older values. When in doubt, GhostBoard prefers to disable a plugin and keep data rather than guess.