Plugins are single-file JS programs the host loads at startup. No npm, no
bundler, no toolchain at runtime — just manifest.json + plugin.js. The
host loader resolves highbeam:* specifiers; everything else is rejected.
This page is the table of contents. The detailed material lives in three sibling docs:
| Doc | Read it when you want to |
|---|---|
| plugin-tutorial.md | Build your first plugin start to finish, with vitest |
| sdk-reference.md | Look up signatures, capabilities, behaviour of highbeam:* |
| plugin-cookbook.md | Find a copy-pasteable recipe for a specific pattern |
| views.md | Return a dynamic, stateful screen instead of a flat result |
For the host's internals — query dispatch, threading, cancellation — see internals.md.
plugins/
my-plugin/
manifest.json required
plugin.js required (or whatever manifest.entry points at)
plugin.log written by the host on demand
[your data files, like spells.json or http.json]
[optional dev-time files: package.json, vitest.config.ts, *.test.js]
{
"name": "my-plugin",
"displayName": "My Plugin",
"version": "0.1.0",
"description": "What it does",
"entry": "plugin.js",
"debounceMs": 0,
"timeoutMs": 500,
"memoryMb": 32,
"capabilities": ["actions"],
"platforms": ["macos", "linux"],
"options": [
{ "key": "github_username", "type": "string", "label": "GitHub username", "default": "" },
{ "key": "result_limit", "type": "int", "label": "Max results", "default": 10, "min": 1, "max": 50 }
]
}import { copy } from 'highbeam:actions';
export async function* query(input, signal) {
if (!input) return;
yield {
key: 'hello',
title: `hello: ${input}`,
subtitle: 'press Enter to copy',
action: copy(input),
};
}That's the entire surface area. Everything else is detail.
In priority order:
--plugins-dir <path>(test override)../plugins/next to the binary's cwd (dev convenience), if present.- Platform default:
- macOS:
~/Library/Application Support/high-beam/plugins/ - Linux:
$XDG_DATA_HOME/high-beam/plugins/
- macOS:
Drop a plugin directory into the chosen location and restart High Beam. There is no hot reload in v1.
Full schema lives in src/plugins/manifest.rs. Unknown fields are
tolerated so new fields can land without breaking older plugins.
name— required; unique; lowercase-kebab; frecency key prefix and cache directory name.displayName,version,description— optional metadata.versionshould be valid semver if you wantupdateto compare it sensibly.entry— optional; defaults toplugin.js.debounceMs— wait this long after the latest keystroke before invokingquery().0= no debounce. Capped at 2000.timeoutMs— wall-clock kill forquery(). Default 500.memoryMb— QuickJS context memory cap. Default 32.capabilities— array of capability strings. See SDK reference for the full table.platforms— optional array. Absent loads everywhere;["macos"]shelves it on Linux;[]shelves it entirely.options— optional array of user-editable settings the host renders in the settings UI. Each entry haskey,type("string","bool","int", or"enum"),label,default, plusmin/max(int) orchoices(enum). Plugins read their own values viahighbeam:settings— see the SDK reference.entryUrl— optional; install URL for a single-file plugin. The installer downloads just this JS file and writes it to<plugin>/<entry>. The common case for plugins with no sibling data files. Mutually exclusive witharchiveUrl.archiveUrl— optional; download URL for a multi-file plugin archive (.tar.gz,.tgz,.tar, or.zip) — for plugins that ship data files alongside the entry script. Mutually exclusive withentryUrl. Declaring one ofentryUrl/archiveUrlis what makes a plugin installable viainstall <url>; with neither it's local-only.manifestUrl— optional; canonical URL hosting this manifest. Theupdatecommand re-fetches it to learn whether a newer version exists. Missing ⇒ plugin opts out of update checks.
Tuning guidance is in the cookbook.
export async function* query(
input: string,
signal: AbortSignal,
): AsyncIterable<Result>;inputis the current query string.signalaborts when the user types another keystroke. Checksignal.abortedbetween yields if you produce many rows; propagate it into I/O calls viaopts.signal.- Yield
Resultobjects, one per row:
type Result = {
key: string; // stable per (plugin, conceptual result)
title: string;
subtitle?: string;
icon?: string; // data:image/<type>;base64,... URI
weight?: number; // 0..100; higher ranks first
pinned?: boolean; // sort above non-pinned regardless of weight
action: Action;
};Plugins can import from the highbeam:* scheme and from the supported
node:* built-ins (node:path, node:fs, node:fs/promises, node:os,
node:zlib, node:string_decoder, node:child_process). Every other
specifier (import 'lodash', import 'node:net', …) is rejected at load
time.
| Capability | Grants |
|---|---|
actions |
highbeam:actions |
http |
global fetch (+ Headers / Request / Response / FormData) |
clipboard.read |
highbeam:clipboard.read |
clipboard.write |
highbeam:clipboard.write |
fs.read |
highbeam:fs.readDir / .readFile / .readText |
fs.cache |
highbeam:fs.readCache / .writeCache |
fs |
full filesystem: node:fs / node:fs/promises + all highbeam:fs.* |
subprocess |
spawn programs (node:child_process) + the process global |
icons |
highbeam:icons.forPath |
system.exec |
highbeam:system.exec |
system.applescript |
highbeam:system.applescript |
highbeam:match, highbeam:settings, node:path,
node:os, node:string_decoder, and node:zlib are uncapped — no
declaration required. fs is the broad "read and write any file your user
can" grant; prefer the scoped fs.read / fs.cache when they suffice. See
sdk-reference.md for per-function behavior, signatures,
and examples.
The runtime is QuickJS (via rquickjs) with the llrt module crates layered on top for the Node and Web API surface. Always-on globals, no capability and no import:
console.log/info/warn/error/debug— see below.setTimeout— awaitable;clearTimeout/clearIntervalare no-ops.URL/URLSearchParams.Buffer,Blob,File.TextEncoder/TextDecoder: multi-encoding (utf-8, utf-16le, utf-16be, windows-1252), with BOM handling; invalid byte sequences decode to U+FFFD.ReadableStreamand the rest of the web-streams family.DOMException.AbortController/AbortSignal: the signal passed toquery()is one.AbortSignal.timeout(ms),AbortSignal.any([...]),AbortSignal.abort(reason)all work;signal.reasonis aDOMExceptionandsignal.throwIfAborted()throws it.crypto: WebCrypto (getRandomValues,randomUUID,subtle.*) plus the node-stylerandomBytes/createHash/createHmachelpers.Temporal: the full Temporal surface (Now,PlainDate,Instant, …).Intl: partial —DateTimeFormatandsupportedValuesOfonly, noNumberFormat/Collator.
fetch (with Headers / Request / Response / FormData) is gated on
the http capability rather than always-on, and the process global is
gated on subprocess. The node:path, node:os, node:zlib,
node:string_decoder, node:fs, node:fs/promises, and
node:child_process modules are imported, not globals (see
sdk-reference.md).
console.log/info/warn/error/debug from a plugin are captured to
<plugin_dir>/plugin.log, prefixed with timestamp + level. No rotation.
[2026-05-20T15:30:42.123Z] [INFO ] hello world
[2026-05-20T15:30:42.456Z] [ERROR] something broke
continuation lines indent four spaces
This is also where capability violations, parse errors, timeouts, and memory-cap hits surface.
Hand-written .d.ts files live in sdk/highbeam/. A minimal
tsconfig.json for a TS plugin:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"strict": true,
"outDir": ".",
"types": ["node"],
"paths": {
"highbeam:*": ["./node_modules/@high-beam/sdk/*"]
}
},
"include": ["plugin.ts"]
}npm install --save-dev <path-to-this-repo>/sdk/highbeam (or symlink it)
to pull the ambient highbeam:* types in. Compiled plugin.js keeps the
bare highbeam:* specifiers — TypeScript only needs the types at compile
time.
Types for the node:* modules come from @types/node
(npm install --save-dev @types/node, hence "types": ["node"] above); the
SDK no longer ships an http.d.ts. The fetch / Response /
AbortSignal / AbortController shapes are in the SDK's types.d.ts.
plugins/echo-ts has a full working setup.
The SDK ships Node-compatible stubs alongside the .d.ts files, so a
plugin can be tested in plain vitest. See
plugin-tutorial.md for the
full setup, and the cookbook recipe
for mocking patterns.
Plugins can ship as install-by-URL packages so users don't have to clone
or copy directories. A publishable manifest declares manifestUrl (so
update can re-fetch it) plus exactly one distribution shape: entryUrl
for a single hosted JS file, or archiveUrl for a multi-file archive.
Most plugins are one plugin.js with no sibling data. Host the script and
the manifest, then point entryUrl at the script:
-
Host
plugin.jsandmanifest.jsonsomewhere your users canGETthem (a Gist's raw URLs, S3, your own CDN — anything HTTP(S)). -
Add
entryUrlandmanifestUrlto yourmanifest.json:{ "name": "my-plugin", "version": "1.0.0", "entryUrl": "https://example.com/my-plugin/plugin.js", "manifestUrl": "https://example.com/my-plugin/manifest.json" }
The installer downloads entryUrl and writes it to <plugin>/<entry>
(entry defaults to plugin.js), then drops the fetched manifest.json
alongside it.
When the plugin ships data files next to its entry script, package the whole directory instead:
-
Build a
.tar.gz(or.zip) of your plugin directory — anything that would land under<user-plugins-dir>/<name>/after extraction. The archive may wrap its contents in a single top-level directory or unpack directly; the host accepts either layout.tar -czf my-plugin.tar.gz -C my-plugin . # or, with the wrapping directory: tar -czf my-plugin.tar.gz my-plugin
-
Host the archive somewhere your users can
GETit (S3, GitHub Releases, your own CDN — anything HTTP(S)). -
Add
archiveUrlandmanifestUrlto yourmanifest.json:{ "name": "my-plugin", "version": "1.0.0", "archiveUrl": "https://example.com/my-plugin/1.0.0/my-plugin.tar.gz", "manifestUrl": "https://example.com/my-plugin/manifest.json" }
Either way, publish the same manifest.json at manifestUrl — the host
re-fetches it on update to discover new versions.
Users install with:
install https://example.com/my-plugin/manifest.json
…and update everything with update. Both commands stream progress in
the launcher result list.
- The fetched manifest must have
name,version, and exactly one ofentryUrl/archiveUrl. A missing pair, or both at once, aborts the install. - If the archive bundles its own
manifest.json, the installer cross-checks it against the URL-fetched manifest:versionmust match exactly (rejected on mismatch).- If the embedded manifest carries
manifestUrl, it must equal the URL the user installed from (rejected on mismatch).
- If the archive does not bundle a
manifest.json, the URL-fetched one is written into the destination dir withmanifestUrlbackfilled to the install URL. - Pre-existing directories at
<user-plugins-dir>/<name>/are removed before the new version is moved into place. There is no on-disk backup — reinstall from the manifest URL to roll back.
version strings should be valid semver — the update command treats
them as such. Non-semver strings always compare as "no update due"
(conservatism: we never replace a working plugin with an unparseable
version label).
.tar.gz/.tgz.tar.zip(DEFLATE only — bzip2 / zstd backends were dropped to keep the binary lean)
Format detection looks at the URL extension first, falls back to the
HTTP Content-Type header.
query()throws → host logs toplugin.log, drops yielded results from this query, renders nothing.- Exceeded
timeoutMs→ host interrupts the context, logs aWARN, drops results. - Exceeded
memoryMb→ host logs anERROR, drops results. - Capability violation at load time → host logs an
ERROR, plugin doesn't load. - Auto-disable on repeated failures is post-v1; every query gets a fresh try.
Every plugin in plugins/ is real and tested. Copy any of them
as a starting point:
echo— minimalcopy(input).echo-ts— TypeScript variant withtsconfig.json.slow-echo— streaming + abort demo.frecency-demo— equal-weight rows to demonstrate pick-bumping.calculator— pinned, inline math evaluator (npm-free shunting-yard).http-codes— useshighbeam:fs.readTextto load bundledhttp.json, opens MDN.paper-size— inlined data,copyaction.dnd— bundled5eSpells.json,match.fuzzyranking,openUrl.app-launcher—fs.readDir,icons.forPath,match.fuzzy, cross-platform Spotlight equivalent.xkcd—fetch+fs.cache-backed title index, fuzzy search.