Stream Kit Docs
Developers

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

NeedPackageNotes
Types (Plugin, PluginAppApi, definitions)@stream-kit/pluginUse import type
Runtime helpers (getFieldValue, parseCommand, cron)@stream-kit/corePreferred for helpers
BaseDirectory, SeekMode@stream-kit/pluginRequired for app.fs options
Svelte UI (custom views/widgets only)@stream-kit/uiDev dependency; externalize at build time
Platform featuresapp (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-message
  • core: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

HookWhen it runs
onLoadAfter settings are hydrated from the plugin store during startup
onEnableWhen the plugin is enabled and dependencies are satisfied (startup and manual toggle)
onReadyAfter all plugins have enabled and actions are loaded
onDisableWhen the plugin is disabled
onSaveAfter 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.js
  • svelte/plugin-host/svelte.js
  • svelte/*/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 → app cn() helper

Plugin updates

Optional manifest fields for self-hosted updates:

  • updateManifestUrl — HTTPS URL to the published manifest
  • downloadUrl — 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 modules

Dashboard 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:

SizeWidthTypical use
xs22remCompact forms (e.g. rename, single field)
sm28remShort forms
md42remDefault; most edit dialogs
lg48remComplex forms with many fields
full50%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 disable

Remote 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();
MemberDescription
userPublic profile, or null when logged out
isAuthenticatedWhether a session is active
login / register / logoutSession helpers (throw on failure)
onChangeSubscribe 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();
MemberDescription
isCloudUrlWhether a field value is a PocketBase /api/files/... ref (relative or absolute)
isOfflineMirrorEnabledWhether this device mirrors cloud files locally (default off)
resolveLocalPathLocal filesystem path when mirror is on (falls back to authenticated cloud URL if download/cache fails); authenticated cloud URL when off
getCachedPathSync lookup of a mirrored path confirmed on disk, or null when not cached yet
syncCacheBackground reconcile onto local disk (no-op when mirror is off)
list / upload / removeManage the signed-in user’s files
getQuotaUsed / max storage and max file size for the active plan
fetchBlobDownload for playback or display (cache-first)
pickOpen 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 });
  }
};
MethodDescription
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' }
        ]
      }
    ]
  });
};
MethodDescription
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.

On this page