Plugin Authoring API
External plugins import their public contract from `@stream-kit/plugin`.
Plugin Authoring API
External plugins import their public contract from @stream-kit/plugin.
First plugin? See Plugin getting started.
Import conventions
| Need | Package | Notes |
|---|---|---|
Types (Plugin, PluginAppApi, definitions) | @stream-kit/plugin | Use import type |
Runtime helpers (getFieldValue, parseCommand, cron) | @stream-kit/core | Preferred for helpers |
BaseDirectory, SeekMode | @stream-kit/plugin | Required for app.fs options |
| Svelte UI (custom views/widgets only) | @stream-kit/ui | Dev dependency; externalize at build time |
| Platform features | app (PluginAppApi) | Filesystem, toast, process, hotkeys, action queues, audio, store |
Never import @tauri-apps/* in plugin code.
Package imports
import type {
Plugin,
PluginAppApi,
PluginCustomViewProps,
HandlerDefinitionProps,
TriggerDefinitionProps,
Action,
ActionTrigger
} from '@stream-kit/plugin';
import { getFieldValue, interpolateVariables } from '@stream-kit/core';@stream-kit/app/api remains available as a compatibility alias inside the app, but new plugins should use @stream-kit/plugin.
Plugin shape
import type { Plugin } from '@stream-kit/plugin';
const plugin: Plugin = (app) => ({
name: 'My Plugin',
handlers: [],
triggers: [],
menuItems: []
});
export default plugin;The app host dynamically imports the plugin entry from the installed manifest and calls app.use(...) internally.
Definition IDs
Trigger and handler definitions receive a stable ID when they are registered. IDs are derived from the plugin manifest key and slugified definition names along the tree path, for example:
twitch:twitch:chat:chat-messagecore:core:map:get-value
These IDs are persisted in saved actions. They stay the same across app restarts, plugin load order, and plugin reloads.
Optional explicit IDs:
{
id: 'chat-message',
name: 'Chat Message',
// ...
}When id is set on a definition, it is appended to the parent scope (twitch:chat:chat-message if the parent scope is twitch:chat). Use explicit IDs only when two siblings would slugify to the same value.
Legacy actions that still reference old index-based IDs (for example twitch:twitch-4:chat-1:chat-message-1) are migrated automatically on startup.
Persistence with PluginStore
Most plugins should persist data through the lifecycle store (PluginStore), backed by plugin.{key}.json:
onLoad: async ({ store }) => {
const saved = await store.get<MySettings>('settings');
if (saved) hydrate(saved);
},
onSave: async ({ store }) => {
await store.set('settings', getSnapshot());
}Use store.get, store.set, and store.delete for settings, lists, and other plugin-owned state. You do not need SQLite unless you have a specific reason to use app.db.
Filesystem (app.fs)
Plugins access the filesystem through app.fs. Import BaseDirectory from @stream-kit/plugin for relative paths:
import { BaseDirectory } from '@stream-kit/plugin';
await app.fs.mkdir('logs', { baseDir: BaseDirectory.AppData, recursive: true });
const text = await app.fs.readTextFile('config.json', {
baseDir: BaseDirectory.AppConfig
});
await app.fs.writeTextFile('logs/events.log', line, {
baseDir: BaseDirectory.AppData,
append: true
});app.fs also supports directory listing, file handles (open / create), copying, renaming, watching, native file/folder pickers via app.fs.select, and save dialogs via app.fs.save.
See Plugin getting started for common BaseDirectory values and zip plugin constraints.
Lifecycle hooks
| Hook | When it runs |
|---|---|
onLoad | After settings are hydrated from the plugin store during startup |
onEnable | When the plugin is enabled and dependencies are satisfied (startup and manual toggle) |
onReady | After all plugins have enabled and actions are loaded |
onDisable | When the plugin is disabled |
onSave | After plugin settings are written to the store |
Use onEnable to start services and connect to external systems. Use onReady for work that depends on the full app (for example auto-connect after actions load).
Host-provided modules
Plugin bundles must externalize host modules:
external: [
'@stream-kit/plugin',
'@stream-kit/plugin/action',
'@stream-kit/core',
'svelte',
'@stream-kit/ui',
'bits-ui',
'runed',
'@iconify/svelte'
]At runtime the app injects an import map:
@stream-kit/plugin→/plugin-host/plugin.js@stream-kit/plugin/action→/plugin-host/action.js@stream-kit/core→/plugin-host/core.jssvelte→/plugin-host/svelte.jssvelte/*→/plugin-host/svelte/*@stream-kit/ui/*→/plugin-host/@stream-kit/ui/*(all Svelte exports from@stream-kit/ui, generated automatically)@iconify/svelte→/plugin-host/@iconify/svelte.js
@stream-kit/ui subpaths, vendor stubs, and the runtime import map are generated from packages/ui/package.json by packages/app/plugin-host-config.mjs when you run pnpm build:plugin-host or pnpm sync:plugin-host. Add new UI components by exporting them from @stream-kit/ui; no manual import-map entries are required.
Install @stream-kit/ui from npm as a dev dependency when you import Svelte components in custom views or widgets. At runtime the app always loads the host bundles — do not bundle @stream-kit/ui into dist/index.js. Match the npm version to the Stream Kit app version you target.
To exclude a subpath from the plugin host (for example internal-only exports), add it to PLUGIN_HOST_UI_EXCLUDED_SUBPATHS in plugin-host-config.mjs.
Action form components and utilities are bundled at build time via aliases (monorepo first-party plugins only):
@stream-kit/plugin/action-ui/*→ app action components@stream-kit/plugin/utils→ appcn()helper
Plugin updates
Optional manifest fields for self-hosted updates:
updateManifestUrl— HTTPS URL to the published manifestdownloadUrl— HTTPS URL to the zip for this version (required in the remote manifest used for updates)sha256— optional zip integrity check
Publish updateManifestUrl and downloadUrl in your distributed zip; the app checks these when update metadata is configured.
Custom plugin pages
Lifecycle hooks receive app: PluginAppApi. Custom Svelte views receive the same API as a prop.
Plugin Svelte components are compiled against the plugin-host runtime. The app mounts them with mount() from /plugin-host/svelte.js instead of rendering them as child components in the main Svelte tree.
<script lang="ts">
import type { PluginCustomViewProps } from '@stream-kit/plugin';
let { app, title, description }: PluginCustomViewProps = $props();
const t = app.i18n.t;
</script>i18n
app.i18n.t('Commands'); // in Svelte templates/components
app.i18n.translate('Saved'); // in .ts modulesDashboard widgets
Plugins can register dashboard widgets alongside menu pages. Widgets reuse customViews for their Svelte components.
import type { Plugin, PluginWidgetProps } from '@stream-kit/plugin';
const plugin: Plugin = (app) => ({
name: 'My Plugin',
customViews: {
'my-widget': MyWidget
},
widgets: [
{
key: 'my-widget',
title: 'My Widget',
description: 'Optional subtitle in the add-widget dialog',
icon: 'ri:layout-grid-line',
columns: 2, // default width: 1 | 2 | 3 | 4 | 5 | 6
view: 'my-widget'
}
]
});Widget components receive PluginWidgetProps:
<script lang="ts">
import type { PluginWidgetProps } from '@stream-kit/plugin';
let { app }: PluginWidgetProps = $props();
const t = $derived(app.i18n.t);
</script>Users place widget instances on the dashboard from the app UI. Layout (order and column width) is persisted in SQLite. See Dashboard.
Actions and modals
const handler = app.actions.findHandler('send-message');
const handlers = app.actions.getHandlers();
const modal =
app.modal.get('edit-item') ??
app.modal.create({
id: 'edit-item',
title: app.i18n.t('Edit item'),
content: EditItemModal,
props: { itemId: 'abc' },
size: 'md' // 'xs' | 'sm' | 'md' | 'lg' | 'full'
});
modal.open();Side panels slide in from the right. Pass size to control width:
| Size | Width | Typical use |
|---|---|---|
xs | 22rem | Compact forms (e.g. rename, single field) |
sm | 28rem | Short forms |
md | 42rem | Default; most edit dialogs |
lg | 48rem | Complex forms with many fields |
full | 50% | Large editors |
Toolbar (app.toolbar)
Register meta badges, primary actions, and bulk-selection controls in the global toolbar below the page header:
app.toolbar.set({
primaryActions: [
{
id: 'add',
label: app.i18n.t('Add item'),
icon: 'ri:add-fill',
onClick: () => openCreateModal()
}
],
selectAll: {
label: app.i18n.t('Select all'),
checked: allSelected,
onChange: (checked) => selectAll(checked)
},
actions: [
{
id: 'delete',
label: app.i18n.t('Delete selected'),
variant: 'destructive',
icon: 'ri:delete-bin-line',
disabled: selectedIds.size === 0,
onClick: () => void deleteSelected()
}
]
});Call app.toolbar.reset() is handled automatically on navigation; update the toolbar from a $effect when selection or counts change.
Bulk actions render as compact link-style controls. Use variant: 'destructive' for delete actions (red text, no fill). primaryActions render as filled buttons and are visually separated from bulk actions.
Global hotkeys (app.hotkeys)
Register system-wide keyboard shortcuts (desktop only, via Tauri global-shortcut):
const unsubscribe = app.hotkeys.register('Shift+P', (context) => {
console.log(context.shortcut, context.modifiers, context.key);
});
unsubscribe();Returns an unsubscribe function. If the shortcut is already taken by another application, registration fails gracefully and a warning toast is shown.
Programmatically fire listeners for a shortcut (same as a physical key press):
app.hotkeys.trigger('Shift+P');Action queues (app.actionQueues)
Control and observe action execution queues:
app.actionQueues.pause(queueId);
app.actionQueues.resume(queueId);
const stats = app.actionQueues.stats(queueId);
const unsubscribe = app.actionQueues.on('job_started', (context) => {
console.log(context.queueName, context.job?.actionName);
});Events: paused, resumed, idle, job_enqueued, job_started, job_completed.
Read app.actionQueues.definitions for the list of configured queues.
WebSocket API server (app.api)
Extend the inbound WebSocket API Server with plugin methods and events. Method and event names are prefixed with plugin:<pluginKey>::
app.api.registerMethod('getLeaderboard', async () => rankings.getTop(10));
await app.api.emit('pointsEarned', { user: 'Alice', points: 5 });
app.api.unregisterMethods(); // onDisable / host also clears on disableRemote clients call plugin:your-key:getLeaderboard and can subscribe to plugin:your-key.*.
Stream Kit account (app.auth)
Read the signed-in public profile and authenticate with email/password. Profile updates stay in the app UI; plugins get public fields only (id, name, avatarUrl, verified, optional subscription { key, name, maxFileBytes, maxStorageBytes, endsAt? }).
const user = app.auth.user; // AuthPublicUser | null
if (app.auth.isAuthenticated) {
console.log(user?.name, user?.avatarUrl, user?.subscription?.name);
}
const stop = app.auth.onChange((next) => {
console.log('auth changed', next?.id ?? null);
});
await app.auth.login({ email: '[email protected]', password: 'secret' });
await app.auth.register({
email: '[email protected]',
password: 'secret',
passwordConfirm: 'secret',
name: 'You'
});
await app.auth.logout();
stop();| Member | Description |
|---|---|
user | Public profile, or null when logged out |
isAuthenticated | Whether a session is active |
login / register / logout | Session helpers (throw on failure) |
onChange | Subscribe to auth changes (fires immediately) |
See Stream Kit account for the in-app UI and PUBLIC_POCKETBASE_URL.
Cloud user files (app.userFiles)
Authenticated media library backed by PocketBase user_files. Requires a signed-in user with an active subscription. Handler file fields (default storage: 'cloud') upload or browse here and store a host-independent /api/files/... ref. When a plan becomes active, the app migrates existing local paths on those fields in the background (same path uploaded once and reused). Upload to cloud still migrates a single field manually.
Entitled accounts can optionally keep a local offline cache of the full library under AppData (Keep cloud files offline on Profile → Cloud files; device-local, default off). When enabled, prefer resolveLocalPath / isOfflineMirrorEnabled so audio/OBS/icons use filesystem paths; handler file fields display the cached local path when available; syncCache shows a neutral progress toast (done / total). When disabled, cloud playback uses authenticated URLs / fetchBlob.
if (app.userFiles.isCloudUrl(value)) {
if (app.userFiles.isOfflineMirrorEnabled()) {
const localPath = await app.userFiles.resolveLocalPath(value);
await app.audio.playFile(localPath);
} else {
const blob = await app.userFiles.fetchBlob(value);
await app.audio.play(blob);
}
}
const uploaded = await app.userFiles.upload(file, { originalName: 'alert.mp3' });
const picked = await app.userFiles.pick({ mimePrefix: 'audio/' });
const quota = await app.userFiles.getQuota();| Member | Description |
|---|---|
isCloudUrl | Whether a field value is a PocketBase /api/files/... ref (relative or absolute) |
isOfflineMirrorEnabled | Whether this device mirrors cloud files locally (default off) |
resolveLocalPath | Local filesystem path when mirror is on (falls back to authenticated cloud URL if download/cache fails); authenticated cloud URL when off |
getCachedPath | Sync lookup of a mirrored path confirmed on disk, or null when not cached yet |
syncCache | Background reconcile onto local disk (no-op when mirror is off) |
list / upload / remove | Manage the signed-in user’s files |
getQuota | Used / max storage and max file size for the active plan |
fetchBlob | Download for playback or display (cache-first) |
pick | Open the app cloud file picker modal |
Chat commands (app.commands)
Requires the Bot plugin. Create, update, and delete chat command records from lifecycle hooks or handlers:
onEnable: async ({ app, pluginKey }) => {
const id = `${pluginKey}:ping`;
const payload = {
name: 'Ping',
commandNames: ['ping'],
group: pluginKey,
handlers: [
{
id: crypto.randomUUID(),
handlerTypeId: 'core:core:chat:send-message',
fields: [{ key: 'message', value: 'Pong!' }]
}
]
};
const exists = app.commands.getSnapshot().some((command) => command.id === id);
if (exists) {
await app.commands.update(id, payload);
} else {
await app.commands.create({ ...payload, id });
}
};| Method | Description |
|---|---|
getSnapshot() | Read all configured commands |
findByTrigger(trigger) | Find a command by trigger string (e.g. !hello) |
runById(id, context) | Execute a command's handler chain |
create(input) | Create a new command record |
update(id, input) | Update an existing command by id |
delete(id) | Delete a command by id |
deleteByOwner(pluginKey) | Delete all commands owned by a plugin |
When called from lifecycle hooks (onEnable, onReady, etc.), ownerPluginKey is set automatically from pluginKey in the context. Commands owned by a plugin are removed when that plugin is uninstalled (not when disabled).
Use stable ids (for example `${pluginKey}:ping`) so re-enabling a plugin can call update instead of creating duplicates. Handler definitions must be registered before creating commands — register handlers on the plugin manifest and create commands in onEnable or onReady.
Owned actions
Plugins can seed default user actions the same way as commands. Actions created from lifecycle hooks get ownerPluginKey automatically from pluginKey.
onEnable: async ({ app, pluginKey }) => {
await app.actions.create({
name: 'Points on follow',
group: pluginKey,
enabled: false,
triggers: [
{
id: crypto.randomUUID(),
triggerTypeId: 'twitch:twitch:channel:new-follower',
conditions: { kind: 'group', id: crypto.randomUUID(), children: [] }
}
],
handlers: [
{
id: crypto.randomUUID(),
handlerTypeId: 'rankings:rankings:add-points',
fields: [
{ key: 'amount', value: 25 },
{ key: 'source', value: 'follow' }
]
}
]
});
};| Method | Description |
|---|---|
getSnapshot() | Read all configured actions |
create(input) | Create a new action record |
update(id, input) | Update an existing action by database id |
deleteByOwner(pluginKey) | Delete all actions owned by a plugin |
Owned actions are removed when the plugin is uninstalled (not when disabled). Use group: pluginKey so seeded actions are easy to find in the Actions UI.
Plugin settings store from a customView
const context = app.plugins.getSettingsContext('bot');
await context?.store.set('key', value);Manifest contract
Every plugin needs a stable manifest key:
{
"key": "hello-world",
"name": "Hello World",
"version": "0.1.0",
"entry": "dist/index.js"
}See Plugin getting started for the full workflow.
Handler field types
Handler definitions can include a fields array. Common field types include text, select, combobox, select-file-or-folder, checkbox, switch, code, color, slider, and key-value-list.
one-of (tabbed input)
Use one-of when the user should pick one of several input methods for the same logical value (for example a file path typed manually vs chosen with a file picker).
{
type: 'one-of',
name: 'Media file',
required: true,
defaultVariant: 'path',
variants: [
{
id: 'path',
label: 'Path',
field: {
type: 'text',
name: 'Path',
placeholder: 'C:/Videos/clip.mp4'
}
},
{
id: 'file',
label: 'File',
field: {
type: 'select-file-or-folder',
mode: 'file',
name: 'File'
}
}
]
}Stored value shape:
{
variant: 'path',
values: {
path: 'C:/Videos/clip.mp4',
file: ''
}
}Inactive variant values are preserved when switching tabs. Validation applies to the active variant only.
Read values in handler execute with:
import { getOneOfFieldValue, resolveOneOfFieldText } from '@stream-kit/core';
const oneOf = getOneOfFieldValue(handler.fields, 'media-file');
const filePath = resolveOneOfFieldText(handler.fields, 'media-file', context, toVariables);Legacy migration
When replacing multiple flat fields with a single one-of, use migrateFrom so existing saved actions keep their values:
migrateFrom: [
{
keys: ['media-file-path', 'media-file'],
variantMap: {
'media-file-path': 'path',
'media-file': 'file'
}
}
]Nested one-of fields inside variants are not supported in v1.
Command parsing
@stream-kit/core exposes helpers for chat command names and argument patterns:
import {
matchCommandPattern,
parseCommand,
parseCommandMessage,
enrichChatMessageWithCommand,
hasCommandArgPlaceholders,
extractCommandArgNames,
RESERVED_COMMAND_ARG_NAMES
} from '@stream-kit/core';
// First token only (legacy)
parseCommand('!setalias CoolUser'); // "setalias"
// Full pattern match with arguments
matchCommandPattern('setalias <target>', '!setalias CoolUser');
// { command: "setalias", args: { target: "CoolUser" } }
// Enrich trigger context from saved Command conditions
enrichChatMessageWithCommand(chatContext, trigger.conditions);Patterns use <argName> placeholders. The last placeholder is greedy and captures the rest of the message. Use {argName} in handler text fields via interpolateVariables.
Plugin getting started
This guide walks you through creating your first Stream Kit plugin from the template through local development and distribution.
WebSocket API Server
Connect remote clients to Stream Kit over a local WebSocket API — actions, commands, variables, collections, queues, overlays, and plugin extensions.