Skip to main content

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

PermissionWhat it allows
networkOutbound HTTP via genie.fetch
notificationsgenie.notifications.*
memory.readRead from memory_entries
memory.writeWrite to memory_entries
calendar.readRead calendar events
calendar.writeCreate/delete calendar events
todos.readRead reminders
todos.writeCreate/complete/delete reminders
events.subscribegenie.events.on(...)
tablet.soundgenie.tablet.playSound(...)
actions.registergenie.actions.register(...)

Settings fields

FieldTypeRequiredDescription
keystringyesProperty name in settings.json
labelstringyesDisplay label
typestringyestext, number, select, toggle, secret, url, oauth
requiredbooleannoMarks field as mandatory
defaultunknownnoDefault value
optionsarraynoFor select type: [{ label, value }]
descriptionstringnoHelp text shown below the field
placeholderstringnoInput 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

NamespaceMethodsPermission
genie.loginfo / warn / error / debugalways
genie.fetch(url, init?)network
genie.settingsget<T>(), onChange(cb)always
genie.secretsget(key), set(key, value), delete(key)always
genie.oauthgetAccessToken(provider)declared in manifest.oauth[]
genie.cronregister(schedule, handler)always
genie.actionsregister(action)actions.register
genie.notificationssend(deviceId, payload), broadcast(payload)notifications
genie.memoryadd(entry), list(query), search(text, opts)memory.read / memory.write
genie.calendarlist(range), add(event), delete(id)calendar.read / calendar.write
genie.todosadd(todo), list(), complete(id), delete(id)todos.read / todos.write
genie.eventson(eventName, handler): unsubscribeevents.subscribe
genie.tabletplaySound(deviceId, assetPath), vibrate(deviceId)tablet.sound
genie.widgetsetDataProvider(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:

EventPayload
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():

  1. Scans each subfolder under data/plugins/.
  2. Parses manifest.json and upserts a row in plugins (does not change enabled).
  3. For every enabled=true row with a present folder, calls loadPlugin(slug).
  4. Folders with no DB row are auto-inserted with enabled=false — the admin sees them on the Plugins page without any extra action.
  5. 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, calls init(genie), registers cron/actions/events.
  • Disable: calls dispose(), unregisters cron jobs (tagged plugin:<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-data and subscribes to plugin.widget-data over WebSocket for live updates.
  • If the manifest has widget.web.entry, the host dynamically imports the compiled widget.js bundle. It expects a default export function Widget({ data, settings, slug }).
  • Otherwise, <SpecWidget> renders the declarative spec.
  • A React ErrorBoundary wraps 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 typeRendered as
text / urlText input
numberNumber input
selectDropdown
toggleToggle switch
secretPassword 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:

ProviderEnv vars
GoogleGENIE_OAUTH_GOOGLE_CLIENT_ID, GENIE_OAUTH_GOOGLE_CLIENT_SECRET
SlackGENIE_OAUTH_SLACK_CLIENT_ID, GENIE_OAUTH_SLACK_CLIENT_SECRET
GitHubGENIE_OAUTH_GITHUB_CLIENT_ID, GENIE_OAUTH_GITHUB_CLIENT_SECRET
MicrosoftGENIE_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");
  1. Server resolves the asset to /api/plugins/<slug>/assets/adhan.mp3.
  2. Emits WS message plugin.tablet.sound { url, slug } to the targeted tablet.
  3. Tablet plays via expo-av. A second call interrupts any currently playing sound.
  4. 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 fileEffect
manifest.jsonRe-parse + DB update + reloadPlugin
server.mjsreloadPlugin
widget.jsBroadcast plugin.widget.bundle-changed { slug, v } — clients re-import without a page reload

Key files

PathPurpose
apps/web/lib/plugins/manifest.tsTypes + parseManifest validator
apps/web/lib/plugins/paths.tsgetPluginsDir(), getPluginDir(slug)
apps/web/lib/plugins/loader.tsBoot reconcile + loadPlugin / unloadPlugin / reloadPlugin
apps/web/lib/plugins/registry.tsIn-memory map of loaded plugins
apps/web/lib/plugins/sdk/One file per genie.* namespace
apps/web/lib/plugins/sdk.d.tsPluginContext type for plugin authors
apps/web/lib/plugins/oauth/Provider configs, token store, refresh helper
apps/web/lib/plugins/events.tsIn-process event bus
apps/web/lib/plugins/secrets.tsAES-256-GCM helpers
apps/web/lib/plugins/widgets.tsWidget data cache + WS push
apps/web/lib/plugins/watcher.tsDev-mode chokidar watcher
apps/web/server/routes/plugins.tsREST surface
apps/web/client/pages/Plugins.tsxPlugins management page
apps/web/client/pages/SettingsBySlug.tsxPer-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.