Capture hooks
Capture hooks are how Beside reacts to specific moments instead of just
indexing everything in the background. A hook subscribes to a slice of the
capture stream (an app, a URL host, an OCR substring), runs custom logic — most
often an LLM call against your local model — and writes structured
HookRecords into its own isolated storage namespace on disk.
Hooks are also the layer that makes Beside proactive without being noisy.
Pre-LLM matchers mean most events never trigger a model call; per-surface
throttling and the global hooks.max_image_bytes / max_prompt_chars ceilings
keep prompts cheap; and per-hook namespaced storage means a hook can never
read another hook’s data — useful when you ship hooks to other people.
Hooks can also expose React widgets that the desktop app mounts on the dashboard, so a hook is a complete vertical: capture matchers + LLM logic + storage records + UI.
Two hook plugins ship with Beside today.
calendar — extract events from screen captures
Watches Apple Calendar, Google Calendar, Outlook web, and iCloud Calendar
screenshots, and writes typed event records (start/end, attendees, links,
location) into its events collection. The desktop app uses the built-in
calendar widget to render them on the dashboard.
hooks:
enabled: true
plugins:
- { name: calendar, enabled: true }
followups — find open threads across communication tools
Reads OCR/accessibility text from Slack, Microsoft Teams, Gmail, Outlook, Apple
Mail, and meeting transcripts, asks the model to identify open follow-ups, and
writes them into the followups collection. Renders via the built-in
followups widget.
hooks:
plugins:
- { name: followups, enabled: true }
How the engine runs your hook
When a RawEvent lands and the frame builder produces text, the hook engine:
- Walks every active
CaptureHookDefinition, matching againstmatch.{inputKinds, apps, appBundleIds, windowTitles, urlHosts, urlPatterns, textIncludes}so most events never trigger an LLM call. - Throttles per surface (
app+window+url+text-hash) usingthrottleMs(or the globalhooks.throttle_ms_default, default60_000). - Loads image bytes only when
needsVision: true. - Calls your
handle(input, ctx)if you provided one — otherwise runs the built-in fallback that combinessystemPrompt+promptTemplate, expects JSON back, and stores it underoutputCollection. - Persists the result through the per-hook namespace
(
ctx.storage.put({ collection, id, data, evidenceEventIds })). - Optionally records
ctx.skip(reason)for any non-fatal "no output" case so the desktop UI can explain why a hook keeps running but never stores anything.
CaptureHookDefinition
interface CaptureHookDefinition {
id: string;
title: string;
description?: string;
match: {
inputKinds?: ('screen' | 'audio')[]; // default ['screen']
apps?: string[]; // substring or /regex/
appBundleIds?: string[];
windowTitles?: string[];
urlHosts?: string[]; // 'calendar.google.com'
urlPatterns?: string[]; // free-form regex
textIncludes?: string[]; // OCR/transcript substring
};
throttleMs?: number;
needsVision?: boolean;
promptTemplate?: string;
systemPrompt?: string;
outputCollection?: string;
widget?: {
id: string;
title: string;
bundlePath?: string; // ship a compiled React bundle
builtin?: 'calendar' | 'followups' | 'list' | 'json';
defaultCollection?: string;
placement?: 'dashboard-main' | 'dashboard-aside';
};
}
Writing a custom hook
The smallest useful hook is a couple of dozen lines.
import type {
IHookPlugin, CaptureHookDefinition, CaptureHookInput, CaptureHookContext, PluginFactory,
} from '@beside/interfaces';
const factory: PluginFactory<IHookPlugin> = () => ({
name: 'pricing-watch',
definitions(): CaptureHookDefinition[] {
return [{
id: 'pricing-watch.threads',
title: 'Pricing thread watcher',
match: {
inputKinds: ['screen'],
apps: ['Slack', 'Mail'],
textIncludes: ['pricing', 'tier', 'discount'],
},
throttleMs: 60_000,
needsVision: false,
outputCollection: 'pricing-threads',
}];
},
async handle(input: CaptureHookInput, ctx: CaptureHookContext) {
if (input.kind !== 'screen') return;
if (!input.ocrText) { ctx.skip?.('no_ocr_text'); return; }
const json = await ctx.model.complete(
`Extract pricing-related decisions from this screen text and return JSON ` +
`with shape { items: [{ title, decision, owner, due }] }.\n\n${input.ocrText}`,
{ responseFormat: 'json', maxTokens: 800 },
);
let parsed: { items?: Array<{ title: string }> } = {};
try { parsed = JSON.parse(json); } catch { ctx.skip?.('bad_json'); return; }
for (const item of parsed.items ?? []) {
await ctx.storage.put({
collection: 'pricing-threads',
id: `${input.app}:${item.title}`,
data: item,
evidenceEventIds: [input.event.id],
});
}
},
});
export default factory;
Storage scoping is enforced by the runtime — your hook can only see records it wrote.
ctx.storage.list({ collection: 'pricing-threads', updatedAfter, limit: 50 });
ctx.storage.get('pricing-threads', id);
ctx.storage.delete('pricing-threads', id);
ctx.storage.clear('pricing-threads');
Hook widgets
If you want your hook to surface in the desktop app, add a widget block to
the definition. Two paths:
- Use a built-in renderer —
calendar,followups,list, orjson. You write zero React. - Ship a compiled bundle — point
bundlePathat a built React bundle and the renderer mounts it inside the dashboard. The renderer hands your bundle the hook records and re-renders on changes.
Widgets get a placement (dashboard-main or dashboard-aside) and a default
collection. Your widget code reads via the same IHookStorageNamespace API.
Global hook controls
hooks:
enabled: true
throttle_ms_default: 60000
max_image_bytes: 2097152 # don't load huge screenshots into LLM calls
max_prompt_chars: 14000 # cap on OCR text per prompt
max_records_per_hook: 2000 # eviction ceiling per hook
plugins:
- { name: calendar, enabled: true }
- { name: followups, enabled: true }
definitions: [] # inline definitions, no plugin needed
You can also declare hook definitions inline under hooks.definitions without
writing a plugin — handy for quick promptTemplate-driven extractors. Built-in
widgets (calendar, followups, list, json) cover the common shapes, so a
useful hook can be a single prompt template with zero React.
