SDK reference
The complete single-file @ghostboard/sdk reference, generated from the published package.
Generated from @ghostboard/sdk 0.1.1
This page is a verbatim copy of
AI_REFERENCE.mdfrom@ghostboard/sdk0.1.1. The same text is installed with the package atnode_modules/@ghostboard/sdk/AI_REFERENCE.mdand included in 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.
capabilitiesin the manifest are user-facing claims, not enforced grants.- The host supplies
@ghostboard/sdk,@ghostboard/sdk/react,react,react-dom,react-dom/client, andreact/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.
runtime/
├── manifest.json
├── app.js
├── csv.js
└── assets/
└── icon.svg
Minimal manifest:
{
"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 }):
import { definePlugin } from '@ghostboard/sdk';
export default definePlugin({
setup(app) {
// Register contributions and owned resources here.
return () => {
// Optional final cleanup.
};
},
});
type Disposer = () => void | Promise<void>;
interface PluginDefinition {
setup(app: PluginContext): void | Disposer | Promise<void | Disposer>;
}
declare function definePlugin<T extends PluginDefinition>(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
interface CommandDefinition {
id: string;
title: string;
shortcut?: string; // for example Mod+Shift+J
enabled?: () => boolean;
run: () => void | Promise<void>;
}
app.commands.register(command: CommandDefinition): Disposer;
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.
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;
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();
},
});
function SummaryPanel() {
return <section>Ready</section>;
}
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:
type JsonValue = null | boolean | number | string | JsonValue[] |
{ [key: string]: JsonValue };
interface PluginStorage {
get(key: string, options?: { signal?: AbortSignal }):
Promise<JsonValue | undefined>;
set(key: string, value: JsonValue, options?: { signal?: AbortSignal }):
Promise<void>;
delete(key: string, options?: { signal?: AbortSignal }): Promise<void>;
list(prefix?: string, options?: { signal?: AbortSignal }): Promise<string[]>;
}
app.storage.app: PluginStorage; // this plugin across workspaces
app.storage.workspace: PluginStorage; // this plugin + workspace
const count = (await app.storage.workspace.get('open-count')) ?? 0;
await app.storage.workspace.set('open-count', Number(count) + 1);
Typed events:
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:
type TaskSchedule = { every: number; at?: never } |
{ at: Date; every?: never };
app.tasks.schedule(id: string, schedule: TaskSchedule,
run: () => void | Promise<void>): 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.
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:
app.content.get(id, { signal? }): Promise<NoteContentSnapshot | null>;
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:
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:
interface NoteContentUpdate {
id: string;
expectedRevision: number;
changes: { title?: string; color?: string; text?: string };
}
app.content.updateMany(updates, { label, operationId?, signal? }):
Promise<ContentBatchResult>;
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.
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<ContentBatchResult>;
app.content.redo(batchId, { operationId?, signal? }):
Promise<ContentBatchResult>;
Selection and React hooks
interface PluginSelection {
get(options?: { signal?: AbortSignal }):
Promise<readonly NoteContentSnapshot[]>;
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:
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:
useSelection(): readonly NoteContentSnapshot[];
useSelectionState(): SelectionSnapshot;
useSelectedPlacements(): PlacementSelectionSnapshot;
SelectionProvider({ selection, children }): ReactElement;
import { useSelectionState } from '@ghostboard/sdk/react';
function SelectionPanel() {
const selection = useSelectionState();
if (selection.status === 'loading') return <p>Loading…</p>;
if (selection.status === 'error') return <p>{selection.error?.message}</p>;
return <p>{selection.items.length} selected Notes</p>;
}
The host wraps registered React panels. Use SelectionProvider only for a root
you create yourself or in standalone tests.
Custom canvas types
interface CanvasTypeDefinition<TState = JsonValue> {
id: string;
title: string;
description?: string;
dataVersion: number;
component: React.ComponentType<CanvasTypeComponentProps<TState>>;
previewComponent?: React.ComponentType<CanvasTypePreviewProps<TState>>;
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:
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.
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:
app.files.pick({
accept: readonly { description: string; extensions: readonly string[] }[];
multiple?: boolean; // at most 16 files
signal?: AbortSignal;
}): Promise<readonly PluginFileHandle[]>;
app.files.pickDirectory({ signal? }): Promise<PluginDirectoryHandle | null>;
app.files.release(...handles): void;
Reads:
app.files.readText(file, { start?, length?, maxBytes?, onProgress?, signal? }):
Promise<string>; // strict UTF-8, default 1 MiB, max 16 MiB collected
app.files.readBytes(file, { start?, length?, maxBytes?, onProgress?, signal? }):
Promise<Uint8Array>; // default max 16 MiB
app.files.stream(file, { start?, length?, onProgress?, signal? }):
ReadableStream<Uint8Array>; // sequential 1 MiB ranges, max 256 MiB
Directories and atomic writes:
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<PluginFileHandle>;
data may be a string, Blob, ArrayBuffer, typed-array view, or
ReadableStream<Uint8Array>. 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.
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:
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:
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, andcleanupCount.behavior.runCommand,runTask,inspectImporterFields,parseImporter,emit,setSelection, andabort.files.addText,addBytes,addDirectory,queuePick, andqueueDirectory.lifecycle.reload(factory)andlifecycle.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:
ghostboard-plugin <runtime-directory> \
--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:
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:
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:
ghostboard-plugin <runtime-directory> \
--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.ghostboardmust be exactly>=a.b.c <x.y.z. A host outside the range reportsincompatible-host-api, runs no plugin code, and keeps the installed files, enablement preference, KV, canvas instances, and imported notes.- Within one host major, every API in this document is additive-only. Breaking changes ship only with a new host major together with a new SDK and scaffold major and a “since host x.y” migration note here. Deprecated APIs are marked at least one host minor before removal.
- The host provides the runtime for
@ghostboard/sdk,@ghostboard/sdk/react, and React / ReactDOM 19.2.x through its import map. The npm package supplies types, the fake host, this reference, and the packer only. Raise the lower bound ofengines.ghostboardwhen you use an API introduced by a newer host. manifestVersion: 1packages are rejected with a notice; their data is kept.- Custom canvas types migrate lazily on
dataVersionincrease with a preimage backup; downgrades never overwrite data. Persistent KV has no migration API, so version your own values. - Unsigned packages cannot be updated in place. Signed updates must advance
semver and keep the pinned public key and update URL; the release document
schema is
ghostboard-plugin-release/v2with a per-releaseghostboardrange and SHA-256. - Supported platform: macOS arm64 (packaged evidence). macOS x64 is pending verification; Windows and Linux are not supported for plugins. URL installs accept public HTTPS only.
Completion checklist
- Keep
manifest.jsonv2 and its app entry inside the packaged runtime files. - Keep plugin-local IDs stable and use migrations for persisted canvas state.
- Keep setup registration-focused; register cleanup and respect
app.signal. - Use stable operation IDs when retrying uncertain content mutations.
- Treat snapshots and file input as untrusted; validate sizes, shapes, fields, revisions, and state.
- Do not use host-private imports, stores, DOM, components, or filesystem paths. Host-owned structures such as Topic trees have no public API; do not reach into internal stores to reach them.
- Test setup and pure behavior with
@ghostboard/sdk/testing. - Build React with shared React/SDK imports externalized.
- Run
bun run packageand install the resulting archive in a packaged app. - Keep signing private keys outside runtime directories and source control.
- Document capabilities honestly. Do not describe unsigned packages, the fake host, or high-trust code as a security boundary.