Making plugins

Extending

A plugin is a .f8 file: a ZIP archive containing a manifest.json and a JavaScript entry file. The entry runs inside a hidden iframe with sandbox="allow-scripts" and no allow-same-origin, so it loads at an opaque origin with no cookies, no localStorage, no parent DOM, and no access to your cloud tokens. Everything it can do arrives through one global object, f8xt, which forwards each call to the host over postMessage. The host checks a permission allowlist before any call runs. Start a new one from the plugin template generator.

The manifest

manifest.json describes the plugin. The id is a reverse-DNS package identifier; updates are matched on it, so keep it stable across versions.

{
  "id": "com.example.my-plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "author": "You",
  "description": "What it does.",
  "entry": "main.js",
  "permissions": ["notes:read", "ui:add"],
  "minAppVersion": "0.2.0",
  "settings": [
    { "key": "greeting", "type": "text", "label": "Greeting", "default": "hi" }
  ]
}

settings is optional. Each entry renders a native control under your plugin in the settings dropdown. Supported types are text, toggle, number, and select (with an options array). Read values with f8xt.settings.get(key) and write them with f8xt.settings.set(key, value). When a user edits a setting, the plugin restarts, so read your settings at startup.

Permissions

Request only what you need. Each permission you list is shown to the user at install; if a later version adds new permissions, the user must re-approve before updating. Network access is separate from reading notes, so a plugin cannot both read your notes and send them somewhere without you seeing both grants.

PermissionGrants
notes:readList and read notes; observe note events; observe typing
notes:writeCreate, edit, and delete notes; replace editor ranges
networkMake network requests via f8xt.net.fetch
ui:addAdd commands, toasts, status bar items, and HTML
ui:modifyModify existing UI
ui:removeRemove existing UI
cssInject custom CSS
cloud:providerRegister a custom cloud storage provider
sandbox:joinShare a sandbox with another plugin
dom:unsandboxedInject raw, unsanitized HTML into the page
administratorAll of the above

dom:unsandboxed and administrator let a plugin reach outside the sandbox. dom:unsandboxed skips HTML sanitization in f8xt.ui.html; the page CSP still blocks script execution in that markup. administrator short-circuits the permission check on every method. Only grant them to plugins you trust.

How a call reaches the host

Each f8xt.* method is a thin wrapper around postMessage. It assigns a request id, stores a resolver, posts { t: "req", id, method, args } to the parent window, and returns a Promise. The host looks up the method in METHOD_PERMISSIONS. If the entry is undefined, it rejects with UNKNOWN_METHOD. If the entry is a permission string and the plugin lacks it (and is not administrator), it rejects with PERMISSION_DENIED. Otherwise it runs the handler and posts the result back. Calls that need no result still return a Promise so you can await confirmation that they ran. Host-to-plugin calls (commands firing, editor typing events, cloud provider operations) use the same envelope in the other direction and time out after 10 seconds.

// Rejects with an Error whose .code is 'PERMISSION_DENIED'
// if the manifest did not list notes:read.
const note = await f8xt.notes.current();

API reference

The 24 methods below are every call a plugin can make. Grouped by namespace. Each entry lists the signature, the permission it needs, what it returns, and the one behavior worth knowing. none means no permission is checked; the method is still sandboxed and rate-limited by the host.

f8xt.plugin

f8xt.plugin.id

none. A string, not a call. The host passes it in at boot; it is the id from your manifest. Useful for namespacing keys in storage or logging.

f8xt.notes

Read and mutate the note store. path arguments are forward-slash paths into the active storage engine (the same paths the sidebar shows). Writes go to disk through the storage bridge; if the path matches the currently open note, the editor content updates in place.

f8xt.notes.list(type?: 'page' | 'journal' | 'whiteboard')

notes:read. Returns [{ name, path, modified }]. Omit type to get all three kinds concatenated. modified is a Unix epoch in milliseconds.

f8xt.notes.read(path: string): Promise<string>

notes:read. Returns the raw file content as a string. Whiteboards are stored as Excalidraw JSON; pages and journals as markdown.

f8xt.notes.write(path: string, content: string): Promise<void>

notes:write. Overwrites the file at path with content. If that note is open in the editor, the in-memory copy updates too; otherwise the write is silent until the user opens it.

f8xt.notes.current(): Promise<{ path, name, content } | null>

notes:read. The note the user is looking at right now, or null if none is open. content is the live buffer, so it includes unsaved typing.

f8xt.notes.create(type: 'page' | 'journal', name: string): Promise<void>

notes:write. Creates a new note of the given kind and opens it. whiteboard is not accepted here; whiteboards are created through the sidebar UI.

f8xt.notes.delete(path: string): Promise<void>

notes:write. Deletes the note at path. If it was open, the editor clears.

f8xt.notes.on(event: 'opened', cb: (e: NoteEvent) => void): void

notes:read. Subscribes to note events. The host currently emits opened when the active note changes. The callback receives { path, name, type }. There is no unsubscribe; the subscription lives until the plugin stops.

f8xt.editor

All three methods act on whichever editor holds focus: the CodeMirror live-preview or the raw textarea. They no-op and return null or false when no editor is active. Offsets are absolute character offsets into the document, not line/column pairs.

f8xt.editor.onType(cb: (e: OnTypePayload) => void): void

notes:read. Fires on user input (not on programmatic dispatches). e is { word, line, from, to, lastChar }, where word is the whitespace-delimited token ending at the cursor and from/to are offsets you can pass straight to replaceRange. This is the hook behind text-expansion snippets.

f8xt.editor.getSelection(): Promise<{ from, to, text } | null>

notes:read. The current selection. text is the selected slice; from and to are equal when nothing is selected.

f8xt.editor.replaceRange(from: number, to: number, text: string): Promise<boolean>

notes:write. Replaces the range [from, to] with text. Offsets are clamped to the document bounds. Returns true if an editor was active, false otherwise. It does not move the cursor.

f8xt.commands

The command palette (Ctrl+P) is populated by plugins and the app alike. Registered commands survive until the plugin stops.

f8xt.commands.register(id: string, title: string, run: () => void): void

ui:add. Adds an entry to the palette. The id is local to your plugin; the host stores it as ${pluginId}:${id}. When the user picks the command, the host calls back into your sandbox with __command and your run fires.

f8xt.commands.execute(id: string): Promise<void>

none. Runs any registered command by its global id. Pass the full pluginId:localId form to target another plugin, or the bare id for app built-ins.

f8xt.ui

f8xt.ui.toast(message: string, opts?: { variant?: 'default' | 'destructive' }): void

ui:add. Shows a toast tagged with your plugin id. Use variant: 'destructive' for errors; the toast renders red.

f8xt.ui.statusBarItem(id: string, text: string): void

ui:add. Writes text into the status bar slot keyed pluginId:id. Call it again with new text to update; call with an empty string to clear. The slot disappears when the plugin stops.

f8xt.ui.html(id: string, html: string): void

ui:add. Mounts html into a region keyed pluginId:id. By default the markup is sanitized through DOMPurify, which strips scripts and event handlers. With dom:unsandboxed or administrator, the raw string is inserted as-is; the page CSP still blocks script execution inside it, so this is for inline event handlers and styling, not for running code.

f8xt.css

Injected stylesheets are scoped to a <style data-f8xt-plugin="groupId"> element in the document head. One sheet per plugin group; a second inject replaces the first.

f8xt.css.inject(css: string): void

css. Inserts the CSS string. Use it for theming overrides or to style your ui.html regions.

f8xt.css.remove(): void

css. Drops the sheet. Also called automatically when the plugin stops.

f8xt.settings and f8xt.storage

Two key-value stores, both permission-free because they are scoped to your plugin. settings is the user-facing one: its keys are declared in the manifest and edited through the settings dropdown. storage is the private one: arbitrary keys your plugin uses for caches, tokens, last-seen timestamps. Both live in the plugin's settings record on disk; storage nests under a __kv sub-object so it never collides with declared settings.

f8xt.settings.get(key: string): Promise<unknown>

none. Returns the current value, or the manifest default if the user has not changed it.

f8xt.settings.set(key: string, value: unknown): Promise<void>

none. Writes the value. This does not restart the plugin; the restart happens only when the user edits through the dropdown.

f8xt.storage.get(key: string): Promise<unknown>

none. Reads from settings.__kv. Returns undefined for unset keys.

f8xt.storage.set(key: string, value: unknown): Promise<void>

none. Writes to settings.__kv. Values must be structured-cloneable (no functions, no DOM nodes) because they cross postMessage.

f8xt.net

f8xt.net.fetch(url: string, opts?: RequestInit): Promise<{ ok, status, statusText, body }>

network. The sandbox CSP blocks connect-src, so this proxy is the only way out. It runs a host-side fetch with your opts and returns the response as text in body. There is no streaming and no access to response headers. Cross-origin requests are subject to the host's CORS, not the sandbox's.

f8xt.cloud

A plugin with cloud:provider can register a storage backend the app treats as a first-class cloud option. Once the user picks it from the provider list, every note read and write routes back into the plugin through provider.* calls. Credentials stay inside the plugin's storage; the app's OAuth tokens are in a separate store the plugin cannot reach.

f8xt.cloud.registerProvider(opts: { id, name, engine }): Promise<void>

cloud:provider. Adds a provider to the picker. engine is an object whose methods become the reverse RPC targets below. Re-registering with the same pluginId:providerId replaces the previous entry.

f8xt.cloud.openAuthPopup(url: string): Promise<Record<string, string>>

cloud:provider. Opens url in a 520x640 popup. When the OAuth flow redirects to the app's /plugin-oauth-callback route, that page relays the query params back over postMessage and this call resolves with them. If the user closes the popup without completing, it resolves with an empty object. Store the token with f8xt.storage.set; the host never sees it.

Methods your engine must implement

Each is invoked by the host as provider.<method> with a 10-second timeout. A timeout rejects the calling storage operation so a dead provider cannot hang sync.

engine = {
  listFiles(path):            Promise<CloudFile[]>,   // { name, path, modified, size? }
  readFile(path):             Promise<string>,         // file body as text
  writeFile(path, content):   Promise<void>,
  deleteFile(path):           Promise<void>,
  createFolder(path):         Promise<void>,
  checkStorageQuota():        Promise<{ used, limit }> // bytes
}

Two plugins, end to end

A keyword expander that turns _now into a timestamp as you type, and a command that appends to the current note.

// manifest.json
{
  "id": "com.example.expander",
  "name": "Expander",
  "version": "1.0.0",
  "entry": "main.js",
  "permissions": ["notes:read", "notes:write"]
}

// main.js
f8xt.editor.onType((e) => {
  if (e.word === '_now') {
    f8xt.editor.replaceRange(e.from, e.to,
      new Date().toISOString().slice(0, 10));
  }
});
// manifest.json
{
  "id": "com.example.stamp",
  "name": "Stamp",
  "version": "1.0.0",
  "entry": "main.js",
  "permissions": ["notes:read", "notes:write", "ui:add"]
}

// main.js
f8xt.commands.register('stamp', 'Add timestamp', async () => {
  const note = await f8xt.notes.current();
  if (!note) { f8xt.ui.toast('open a note first', { variant: 'destructive' }); return; }
  await f8xt.notes.write(note.path, note.content + '\n\n' + new Date().toISOString());
  f8xt.ui.toast('stamped');
});

Packaging

A .f8 is a plain ZIP with manifest.json and your entry file at the top level. Optional siblings are styles.css (injected automatically on start) and icon.png (shown in the settings dropdown). The template generator builds a valid, ready-to-edit .f8 from a short form, or you can zip the files yourself and rename the archive to .f8.