Plugins
The plugin system lets you extend Open Genie with new home-page widgets, settings pages, LLM actions, cron jobs, and third-party integrations — without touching Open Genie's source code. Plugins are self-contained folders uploaded as .zip files from the Plugins page (/plugins).
Open Genie ships with zero plugins installed. Every feature delivered by a plugin comes from that folder alone.
Plugin folder layout
A plugin lives at data/plugins/<slug>/:
data/plugins/prayer-times/
├── manifest.json # required — single source of truth
├── settings.json # auto-created when the user saves settings
├── server.mjs # required if manifest.server.entry is set
├── widget.js # compiled React component (no JSX) — optional
├── icon.svg # optional, referenced from manifest.iconPath
└── assets/ # optional — sounds, images, fixtures
└── adhan.mp3
The plugins directory defaults to data/plugins/ under getDataRoot() and can be overridden with the GENIE_PLUGINS_PATH env var.
manifest.json reference
manifest.json is the only file Open Genie reads to decide what a plugin contributes.
interface PluginManifest {
slug: string; // kebab-case, unique — used in URLs and folder names
name: string;
version: string; // semver
description: string;
author: string;
iconPath?: string; // relative to plugin folder, e.g. "icon.svg"
permissions: PluginPermission[];
server?: { entry: string }; // e.g. "server.mjs"
widget?: {
title: string;
size: "small" | "medium" | "large";
refreshIntervalSec?: number;
web?: { entry: string }; // e.g. "widget.js" — compiled React bundle
spec: WidgetSpec; // declarative spec — required when widget is set
};
settings?: { fields: SettingsField[] };
cron?: Array<{ schedule: string; handler: string }>;
oauth?: Array<{
provider: "google" | "slack" | "github" | "microsoft";
scopes: string[];
}>;
actions?: Array<{
name: string; // namespaced as <slug>.<name> at runtime
description: string;
parameters: JSONSchema;
}>;
}
Permissions
| Permission | What it allows |
|---|---|
network | Outbound HTTP via genie.fetch |
notifications | genie.notifications.* |
memory.read | Read from memory_entries |
memory.write | Write to memory_entries |
calendar.read | Read calendar events |
calendar.write | Create/delete calendar events |
todos.read | Read reminders |
todos.write | Create/complete/delete reminders |
events.subscribe | genie.events.on(...) |
tablet.sound | genie.tablet.playSound(...) |
actions.register | genie.actions.register(...) |
Settings fields
| Field | Type | Required | Description |
|---|---|---|---|
key | string | yes | Property name in settings.json |
label | string | yes | Display label |
type | string | yes | text, number, select, toggle, secret, url, oauth |
required | boolean | no | Marks field as mandatory |
default | unknown | no | Default value |
options | array | no | For select type: [{ label, value }] |
description | string | no | Help text shown below the field |
placeholder | string | no | Input placeholder |
Widget spec DSL
The spec drives the tablet renderer and serves as a fallback for web when no widget.js is provided. Every block type has a fixed renderer — no eval(), no plugin code on the device.
interface WidgetSpec {
layout: "stack" | "row" | "grid";
blocks: WidgetBlock[];
}
type WidgetBlock =
| { type: "header"; title: string; accent?: string }
| { type: "text"; bind: string; size?: "sm"|"md"|"lg"; weight?: "regular"|"bold"; muted?: boolean }
| { type: "row"; blocks: WidgetBlock[]; gap?: number }
| { type: "list"; bind: string; itemTemplate: WidgetBlock[]; emptyText?: string }
| { type: "icon"; bind: string; size?: number }
| { type: "image"; bind: string; width?: number; height?: number }
| { type: "progress"; bind: string; max?: number; tint?: string }
| { type: "badge"; bind: string; tint?: string }
| { type: "spacer"; size?: number };
bind is a path expression evaluated against the data payload returned by widget.getData() — e.g. "upcoming.0.title", "current.tempC". Literal strings are expressed as { "literal": "Today" }.
Plugin SDK (genie namespace)
A plugin's server.mjs exports two lifecycle functions:
export async function init(genie) {
// register cron, set widget data provider, subscribe to events, etc.
}
export async function dispose() {
// optional — called when the plugin is disabled
}
genie is a PluginContext object. Each namespace is permission-gated — a missing permission causes the first call to throw PermissionDenied.
Namespaces
| Namespace | Methods | Permission |
|---|---|---|
genie.log | info / warn / error / debug | always |
genie.fetch | (url, init?) | network |
genie.settings | get<T>(), onChange(cb) | always |
genie.secrets | get(key), set(key, value), delete(key) | always |
genie.oauth | getAccessToken(provider) | declared in manifest.oauth[] |
genie.cron | register(schedule, handler) | always |
genie.actions | register(action) | actions.register |
genie.notifications | send(deviceId, payload), broadcast(payload) | notifications |
genie.memory | add(entry), list(query), search(text, opts) | memory.read / memory.write |
genie.calendar | list(range), add(event), delete(id) | calendar.read / calendar.write |
genie.todos | add(todo), list(), complete(id), delete(id) | todos.read / todos.write |
genie.events | on(eventName, handler): unsubscribe | events.subscribe |
genie.tablet | playSound(deviceId, assetPath), vibrate(deviceId) | tablet.sound |
genie.widget | setDataProvider(fn), invalidate() | always |
genie.fetch
Wraps native fetch with a default 10-second timeout and blocks requests to private/loopback IP addresses (DNS-rebinding protection). Set GENIE_PLUGIN_FETCH_ALLOW_LOCAL=1 to bypass in development.
genie.settings
Reads data/plugins/<slug>/settings.json merged over schema defaults. onChange(cb) fires within ~500 ms of a hand-edit to the file.
genie.secrets
AES-256-GCM encrypted, stored in plugin_secrets. Key comes from GENIE_SECRETS_KEY (32-byte hex). If unset, plugins with secret or OAuth fields cannot be enabled.
genie.widget
setDataProvider(fn) registers the async function that returns widget data. invalidate() calls the provider, caches the payload, and broadcasts plugin.widget-data over WebSocket to all subscribers. Auto-invoked on the manifest.widget.refreshIntervalSec interval.
Event bus
Plugins can subscribe to internal Open Genie events via genie.events.on(...). Open Genie emits:
| Event | Payload |
|---|---|
memory.created | { entryId, category, content } |
memory.updated | { entryId, content } |
calendar.event.created | { eventId, title, startAt } |
todo.created | { todoId, title } |
todo.completed | { todoId, title } |
iot.event | { deviceId, kind, event, detail } |
camera.alert | { cameraId, level, message } |
chat.message.user | { conversationId, deviceId, text } |
boot.ready | {} — fired after all plugins finished init() |
Lifecycle
Boot reconcile
On server start, reconcilePlugins() runs after loadSkills():
- Scans each subfolder under
data/plugins/. - Parses
manifest.jsonand upserts a row inplugins(does not changeenabled). - For every
enabled=truerow with a present folder, callsloadPlugin(slug). - Folders with no DB row are auto-inserted with
enabled=false— the admin sees them on the Plugins page without any extra action. - DB rows with no folder are flagged as missing — displayed grayed-out in the UI, safe to uninstall.
Install flow
User uploads plugin.zip
→ POST /api/plugins/install (extract, validate manifest)
→ Permission dialog shown in UI
→ POST /api/plugins/<slug>/enable (user accepts)
→ loadPlugin(slug): dynamic import server.mjs, call init(genie)
→ Redirect to /plugins
Enable / disable (hot, no restart)
- Enable: loads
server.mjs, callsinit(genie), registers cron/actions/events. - Disable: calls
dispose(), unregisters cron jobs (taggedplugin:<slug>), removes action registrations, tears down event subscriptions, drops the WS data provider.
Home-page widget host
Plugin widgets render below the existing first-party widget grid on the web home page (/).
<PluginWidgets />(server component) reads enabled plugins and renders one<PluginWidgetHost slug=… />per entry.<PluginWidgetHost>(client component) fetches initial data from/api/plugins/<slug>/widget-dataand subscribes toplugin.widget-dataover WebSocket for live updates.- If the manifest has
widget.web.entry, the host dynamically imports the compiledwidget.jsbundle. It expects a default exportfunction Widget({ data, settings, slug }). - Otherwise,
<SpecWidget>renders the declarative spec. - A React
ErrorBoundarywraps each host — a crashing plugin shows an error card; other widgets continue rendering.
Tablet renders plugin widgets using <PluginSpecWidget>, a recursive native renderer built from @genie/ui primitives. Plugin code never runs on the device.
Per-plugin settings page (/settings/<slug>)
Each plugin with a settings block in its manifest gets a settings page at /settings/<slug>. The form is driven entirely by manifest.settings.fields:
| Field type | Rendered as |
|---|---|
text / url | Text input |
number | Number input |
select | Dropdown |
toggle | Toggle switch |
secret | Password input — value never returned by GET |
oauth | "Connect <Provider>" / "Connected as X — Disconnect" |
Saving POSTs to /api/plugins/<slug>/settings. Non-secret values go to settings.json; secret values go to plugin_secrets. The plugin's genie.settings.onChange fires automatically via the file watcher.
OAuth
Open Genie owns the full OAuth round-trip; plugins never handle redirect URLs or tokens directly.
GET /api/plugins/oauth/<provider>/connect?slug=<slug>
→ 302 to provider's authorize URL
GET /api/plugins/oauth/<provider>/callback?code=…&state=…
→ Exchange code, store encrypted token, 302 to /settings/<slug>
Supported providers: google, slack, github, microsoft. Provider credentials are set via env vars:
| Provider | Env vars |
|---|---|
GENIE_OAUTH_GOOGLE_CLIENT_ID, GENIE_OAUTH_GOOGLE_CLIENT_SECRET | |
| Slack | GENIE_OAUTH_SLACK_CLIENT_ID, GENIE_OAUTH_SLACK_CLIENT_SECRET |
| GitHub | GENIE_OAUTH_GITHUB_CLIENT_ID, GENIE_OAUTH_GITHUB_CLIENT_SECRET |
| Microsoft | GENIE_OAUTH_MICROSOFT_CLIENT_ID, GENIE_OAUTH_MICROSOFT_CLIENT_SECRET |
genie.oauth.getAccessToken("google") auto-refreshes the stored token if it expires within 60 seconds.
Permission model (v1)
At install time a dialog lists every permission the plugin declares. The user must check "I trust this plugin" to proceed. The granted set is persisted in plugins.permissionsGranted.
SDK methods check the granted set lazily on first call. A missing permission throws PermissionDenied(<name>) — the plugin enters a "broken" state in the registry (shown in red in the UI) but does not crash other plugins.
Sound playback on tablet
A plugin can trigger audio on a paired tablet without shipping any tablet code:
await genie.tablet.playSound(deviceId, "assets/adhan.mp3");
- Server resolves the asset to
/api/plugins/<slug>/assets/adhan.mp3. - Emits WS message
plugin.tablet.sound { url, slug }to the targeted tablet. - Tablet plays via
expo-av. A second call interrupts any currently playing sound. - If no tablet is connected, the call no-ops with a
genie.log.warn.
Dev-mode hot reload
When NODE_ENV !== "production" (or GENIE_PLUGINS_HOT_RELOAD=1), a chokidar watcher monitors each loaded plugin's files:
| Changed file | Effect |
|---|---|
manifest.json | Re-parse + DB update + reloadPlugin |
server.mjs | reloadPlugin |
widget.js | Broadcast plugin.widget.bundle-changed { slug, v } — clients re-import without a page reload |
Key files
| Path | Purpose |
|---|---|
apps/web/lib/plugins/manifest.ts | Types + parseManifest validator |
apps/web/lib/plugins/paths.ts | getPluginsDir(), getPluginDir(slug) |
apps/web/lib/plugins/loader.ts | Boot reconcile + loadPlugin / unloadPlugin / reloadPlugin |
apps/web/lib/plugins/registry.ts | In-memory map of loaded plugins |
apps/web/lib/plugins/sdk/ | One file per genie.* namespace |
apps/web/lib/plugins/sdk.d.ts | PluginContext type for plugin authors |
apps/web/lib/plugins/oauth/ | Provider configs, token store, refresh helper |
apps/web/lib/plugins/events.ts | In-process event bus |
apps/web/lib/plugins/secrets.ts | AES-256-GCM helpers |
apps/web/lib/plugins/widgets.ts | Widget data cache + WS push |
apps/web/lib/plugins/watcher.ts | Dev-mode chokidar watcher |
apps/web/server/routes/plugins.ts | REST surface |
apps/web/client/pages/Plugins.tsx | Plugins management page |
apps/web/client/pages/SettingsBySlug.tsx | Per-plugin settings page |
apps/web/client/components/plugin-widgets/ | Web widget host components |
Minimal example
// data/plugins/hello/manifest.json
{
"slug": "hello",
"name": "Hello Plugin",
"version": "1.0.0",
"description": "A minimal example",
"author": "You",
"permissions": [],
"server": { "entry": "server.mjs" },
"widget": {
"title": "Hello",
"size": "small",
"spec": {
"layout": "stack",
"blocks": [
{ "type": "header", "title": "Hello" },
{ "type": "text", "bind": "message" }
]
}
}
}
// data/plugins/hello/server.mjs
export async function init(genie) {
genie.widget.setDataProvider(async () => ({ message: "Hello, world!" }));
}
Package as a zip (zip -r hello.zip hello/) and upload from the Plugins page.