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 {
Action,
ActionTrigger,
HandlerDefinitionProps,
Plugin,
PluginAppApi,
PluginCustomViewProps,
TriggerDefinitionProps
} 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.
Settings sections
Group settings fields with type: 'section'. Sections can open and close, show a status badge, and contain nested sections (one level deep):
settings: [
{
type: 'section',
title: 'ElevenLabs',
icon: 'ri:voiceprint-line',
collapsible: true,
badge: ({ getValue }) =>
getValue('apiKey')
? { label: 'Connected', variant: 'success' }
: { label: 'Not configured', variant: 'outline' },
fields: [
{ key: 'apiKey', type: 'text', inputType: 'password', name: 'API key' },
{
type: 'section',
title: 'Advanced',
collapsible: true,
fields: [/* … */]
}
]
}
];| Option | Description |
|---|---|
collapsible | Render the section as a panel that opens and closes |
defaultOpen | Initial open state. Collapsible sections start closed (false) by default. A function is resolved once when the section first renders |
badge | Status badge in the header (label, optional variant), also visible while closed |
icon | Iconify icon next to the title |
fields | Fields or nested sections. Field keys do not depend on nesting |
A closed section opens automatically when one of its fields fails validation.
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 its manifest dependencies are enabled (startup and manual toggle). Plugins start after their dependencies. |
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
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.
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 modulesCopy to clipboard
Use CopyButton from @stream-kit/ui/copy-button for any "copy this value" action, so it looks and behaves like the rest of the app: a copy icon with a tooltip that turns into a green double check for two seconds after copying.
<script lang="ts">
import { CopyButton } from '@stream-kit/ui/copy-button';
</script>
<!-- Icon-only, with tooltip -->
<CopyButton
value={voice.id}
label={t('Copy voice ID')}
copiedLabel={t('Copied')}
onCopied={() => app.toast.create({ title: t('Voice ID copied'), variant: 'success' })}
/>
<!-- With visible text; `value` can also be a function that resolves on click -->
<CopyButton variant="outline" value={() => buildUrl()}>{t('Copy URL')}</CopyButton>CopyButton accepts the regular Button props (variant, size, disabled, class). It stops click propagation, so it is safe inside clickable cards. For read-only fields, use <InputText copyable copyLabel={…} copiedLabel={…} />. When you need a custom trigger, use CopyFeedback (copied state per key) together with COPY_ICON and COPIED_ICON from the same module.
To add more buttons to a field, pass actions. They join the copy and show-password buttons on the right side of the input:
<InputText
type="password"
readonly
value={secret}
copyable
actions={[{ icon: 'ri:refresh-line', label: t('Regenerate'), onClick: regenerate }]}
/>Pagination
Long lists (hundreds of rows or more) make a page slow when every row renders at once. Split them into pages with @stream-kit/ui/pagination:
Paginatorslices a reactive list on the client. Pass a getter, so the pages follow search, filters and deletes.pagestays within the available pages, so deleting the last row of the last page moves you back one page.Paginationrenders the controls: previous/next, page numbers with…for collapsed ranges, and an optionalsummaryon the left. It renders nothing when there is only one page.
<script lang="ts">
import { Pagination, Paginator } from '@stream-kit/ui/pagination';
let search = $state('');
const visible = $derived(items.filter((item) => item.name.includes(search)));
const pages = new Paginator(() => visible, { pageSize: 50 });
</script>
<input bind:value={search} oninput={() => pages.reset()} />
{#each pages.items as item (item.id)}
<Row {item} />
{/each}
<Pagination
bind:page={pages.page}
pageCount={pages.pageCount}
summary={t('{from}–{to} of {total}', {
from: pages.start + 1,
to: pages.end,
total: pages.total
})}
label={t('Pages')}
previousLabel={t('Previous page')}
nextLabel={t('Next page')}
pageLabel={(page) => t('Page {page}', { page })}
/>Paginator exposes page (1-based, settable), pageCount, total, start, end and items, plus reset() to jump back to page 1. Pagination also works on its own for server-side paging: bind page, pass pageCount, and load the page in onPageChange. siblings (default 1) sets how many page numbers show next to the current page. The labels default to English, so pass translated ones.
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 (narrow) | 2 (wide)
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>The dashboard draws the card, header (icon, title, description as tooltip) and the drag/menu controls — your component renders only the body. columns sets the default width: 1 is narrow (one masonry column), 2 is wide (two columns). Cards size to their content, so keep widgets compact. Use the building blocks from @stream-kit/ui/widget so your widget matches the core ones:
| Component | Use for |
|---|---|
WidgetStat | A single metric: label, value, optional total (3 / 12), hint and href |
WidgetList + WidgetRow | Compact rows with icon (or leading snippet), title, description and a trailing snippet for badges/buttons. Pass href or onclick to make the row clickable |
WidgetFooterLink | "View all …" link at the bottom of the widget |
<script lang="ts">
import type { PluginWidgetProps } from '@stream-kit/plugin';
import { EmptyState } from '@stream-kit/ui/empty-state';
import { WidgetFooterLink, WidgetList, WidgetRow } from '@stream-kit/ui/widget';
let { app }: PluginWidgetProps = $props();
const t = $derived(app.i18n.t);
</script>
{#if items.length === 0}
<EmptyState compact icon="ri:inbox-line" title={t('Nothing here yet')} />
{:else}
<WidgetList>
{#each items as item (item.id)}
<WidgetRow icon="ri:star-line" title={item.name} href={item.href} />
{/each}
</WidgetList>
{/if}
<WidgetFooterLink href="/plugins/my-plugin/items">{t('View all')}</WidgetFooterLink>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 trigger = app.actions.findTrigger('twitch:twitch:chat:chat-message');
const triggers = app.actions.getTriggers();
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.
For push-to-talk, pass onRelease. The press handler then runs once per hold (no key repeat) and onRelease runs when the key is let go:
const unsubscribe = app.hotkeys.register('F13', () => startTalking(), {
onRelease: () => stopTalking()
});A global shortcut is captured by Stream Kit, so other apps (like the game) don't receive that key.
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.*.
Listen for HTTP calls on /webhook/<path> with registerWebhook. Paths are not prefixed and match case-insensitively. Return { timeoutMs } to keep the caller waiting until you call respond(); return nothing and the server answers 202 right away:
const off = app.api.registerWebhook(
'scene/next',
(request) => {
void nextScene().then(() => request.respond({ status: 200, body: 'ok' }));
return { timeoutMs: 5_000 };
},
{ secret }
);
// later: off();Callers authenticate with secret (Authorization: Bearer, X-Webhook-Secret or ?secret=). Without a secret they use the API server token. The host checks it before your listener runs and strips it from the request. request has method, path, query, headers (lowercase names), body (text) and remoteAddr.
app.api.getWebhookUrl(path) returns the full URL, or null while the API server is stopped. It is reactive, so you can show it in a view.
HTTP requests (app.http)
Send HTTP requests from the app backend. Unlike fetch in a plugin, these are not blocked by CORS, so any URL works:
const response = await app.http.request({
method: 'POST',
url: 'http://192.168.1.20:8088/api/',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Function: 'Cut' }),
timeoutMs: 10_000
});
if (response.status >= 400) console.warn(response.body);The promise rejects on network errors and invalid URLs; HTTP error statuses resolve normally. Only http and https are allowed, the timeout defaults to 30 seconds (max 2 minutes), and bodies above 5 MB are cut off (truncated: true).
Speech recognition (app.speech)
Local speech-to-text with Whisper (whisper.cpp, running in the app backend). Audio never leaves the PC. The runtime (about 9 MB) and a model (30 MB to 900 MB) are downloaded once, with progress and SHA-256 verification:
const runtime = await app.speech.getRuntimeInfo();
if (!runtime.installed) {
await app.speech.downloadRuntime({ onProgress: (p) => console.log(p.phase, p.receivedBytes) });
}
const models = await app.speech.listModels(); // `recommended` marks the best fit for this PC
await app.speech.downloadModel('small-q5_1', { signal: controller.signal });
await app.speech.configure({ modelId: 'small-q5_1', language: 'nl' });
await app.speech.start(); // loads the model and opens the microphone
await app.speech.setContinuous(true); // every sentence becomes a transcript
const stop = app.speech.onTranscript((transcript) => {
if (transcript.final) console.log(transcript.source, transcript.text);
});| Member | Description |
|---|---|
status / onStatusChange | { state, continuous, pushToTalk, modelId, deviceName, error, warning }. warning is mic-silent (Windows blocks the microphone), device-fallback or server-restarted |
getRuntimeInfo | { installed, version, missingDependencies, vcRedistUrl }; whisper.cpp needs the Microsoft Visual C++ Redistributable (x64) |
listModels / downloadModel / deleteModel | Multilingual Whisper models, from Tiny to Large v3 Turbo |
listInputDevices | Microphones; pass an id as deviceId to configure |
configure(config) | modelId, deviceId, language (auto or ISO 639-1), threads, vadSensitivity, partials (interim text, more CPU), prompt (words to recognize better), pushToTalkPrompt (expected command phrases for push-to-talk; makes short commands far more reliable). Only a new model or thread count reloads a running recognizer |
start / stop | Open or close the microphone and load or unload the model |
setContinuous(enabled) | Transcribe every sentence (captions, wake words) |
beginPushToTalk / endPushToTalk | One utterance with source: 'push-to-talk'; ends on its own after 30 seconds |
onTranscript | { id, text, final, source, startedAt, durationMs, inferenceMs, language, confidence }. Interim and final transcripts of a sentence share id. confidence is the average word probability: with a prompt, noise can come back as a prompt phrase at ~0.55, so ignore commands below ~0.65 |
onLevel | Microphone level (0–1) about ten times a second, for a meter |
Silence is never sent to Whisper, and common hallucinations (subtitle credits, "thanks for watching") are filtered out. The Voice plugin builds commands, captions and translation on top of this API.
AI (app.ai)
Run AI tasks on the Stream Kit cloud. Every call spends the signed-in user's daily AI credits, so only call it in response to something the user set up (a handler, a trigger they enabled, a button).
The first AI call from your plugin asks the user whether it may use AI. The answer is remembered and can be changed under AI access on your plugin's page. Until the user allows it, calls wait for the answer; after a "no" they reject with code not-allowed.
if (app.ai.available) {
const { text } = await app.ai.generateText({
prompt: `Write a short welcome for ${user}, who just followed.`,
instructions: 'Playful, in Dutch',
length: 'short'
});
const { label, confidence } = await app.ai.classify({
text: message,
labels: [
{ key: 'schedule', description: 'Asks when the next stream is' },
{ key: 'setup', description: 'Asks about hardware or software' }
]
});
}| Member | Credits | Description |
|---|---|---|
available | true when signed in on a plan that includes AI | |
generateText(options) | 2 | Short, stream-safe text without markdown. prompt up to 2000 characters, optional instructions (style or persona, up to 1000) and length (short: up to 300 characters, medium: up to 1200) |
translate(options) | ≈1 per 1000 characters | Translates up to 500 characters into targetLanguage (ISO 639-1); pass sourceLanguage when known. Charged by size, so continuous captions stay cheap |
classify(options) | 1 | Picks one of 1–20 labels ({ key, description? }) for text, or label: null when none clearly applies. The text is treated as data, so chat messages can't steer the result. confidence (0–1) is the model's own estimate, not calibrated |
usage / getUsage() | 0 | Today's credits: { used, limit, remaining, resetsAt }. Credits reset at midnight UTC |
onUsageChange(handler) | Fires immediately and after every call. Use it in plugin views, which run in a separate Svelte runtime |
Both task methods accept a signal to cancel. They reject with an Error whose code says why:
code | Meaning |
|---|---|
unauthenticated / subscription-required | No account, or no active plan that includes AI |
not-allowed | The user didn't allow your plugin to use AI |
quota-exceeded | Out of credits until usage.resetsAt |
rate-limited | Your plugin made more than 60 calls in a minute or 4 at once |
busy | The AI service is overloaded; try again later |
invalid-input | Input outside the limits above (the message says which) |
refused | The AI declined the request |
aborted / unavailable / failed | Cancelled, AI not available in this build, or any other failure |
Don't feed raw chat into generateText and post the result automatically: viewers can steer the text. Use classify to decide, and keep the generated text to prompts you control.
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, aiEnabled, 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();
// Authenticated call to a custom Stream Kit cloud route (PocketBase hook)
const result = await app.auth.send<{ ok: boolean }>('/api/my-route', {
method: 'POST',
body: { hello: 'world' }
});| Member | Description |
|---|---|
user | Public profile, or null when logged out |
isAuthenticated | Whether a session is active |
send(path, options?) | Authenticated request to a Stream Kit cloud route. Rejects with an Error carrying the HTTP status and server message. Plugins can only call routes Stream Kit has granted them; other paths reject |
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 and overlay manifest file settings (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. Turning the setting on shows a neutral progress toast with a bar and done / total; background syncs stay silent. 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). Pass { notify: true } for the progress-bar toast. |
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 your plugin (other keys throw) |
openDraft(input, { onSaved? }) | Open the action editor with an unsaved draft. The user saves it; the result is user-owned (no ownerPluginKey). onSaved(record) runs once after the first save |
getTriggers() / findTrigger(id) | Read registered trigger definitions |
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('my-plugin');
await context?.store.set('key', value);To change a value that is also a settings field, use app.plugins.setSettingValue. It updates the settings form, persists the value, and runs the plugin's onSave hook:
await app.plugins.setSettingValue('my-plugin', 'apiKey', newKey);A plugin can only use its own key for getSettingsContext, setSettingValue, db.registerMigrations, owner keys on actions/commands, and deleteByOwner; another key throws. getSettingValue can also read core settings. Scripts run on your behalf and may access any plugin.
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.
condition-group (AND/OR conditions)
Use condition-group when a handler needs one or more conditions combined with AND/OR, like the core If handler. Users can add conditions, negate them and group them; the value is a condition tree (ConditionGroupNode).
{
type: 'condition-group',
name: 'Condition',
required: true,
conditions: [
{
key: 'compare',
type: 'text-select-text',
name: 'Compare',
items: [
{ value: 'equals', label: 'Equals' },
{ value: 'isEmpty', label: 'Is empty' }
],
valuelessOperators: ['isEmpty']
}
]
}Set migrateFromTextSelectText: '<condition key>' when a field that used to store a single text-select-text value becomes a condition group; stored values are converted on load.
Variable autocomplete
Text, text-select-text, key-value-list, condition-group and JSON fields offer {variable} autocomplete for every variable in scope. Set variables: false on a field to turn it off (for example a field that holds a variable name). On text and json fields, variables can also be an array of extra variables to suggest. useContextVariables is deprecated and no longer needed.
Handler outputs and trigger variables
Declare which variables a handler sets so later handlers can autocomplete them. Use a function when the key comes from a field value:
export const createLookupHandler = () =>
({
name: 'Lookup user',
fields: [{ type: 'text', name: 'Target name', defaultValue: 'user', variables: false }],
outputs: ({ getFieldValue }) => {
const target = getFieldValue('target-name');
return typeof target === 'string' ? [{ key: target }] : [];
},
execute: async (_action, handler, context, next) => {
// ... sets context.actionVariables[target]
next();
}
}) satisfies HandlerDefinitionProps;Static outputs are a plain array: outputs: [{ key: 'streamActive' }, { key: 'streamDuration' }]. A variable set in only one branch of an If handler is suggested as maybe to handlers after the If.
Triggers derive their variables from onTest sample data. When a trigger has no useful sample, declare them with variables: [{ key: 'username', description: 'Chatter login' }] (or a function of the configured trigger).
Handlers without outputs still get basic suggestions from target-name, or variable-name with action scope, but declaring outputs is preferred.
Timeouts
Each handler may run for 2 minutes before Stream Kit aborts it, so one stuck handler never blocks an action queue. On timeout context.signal aborts, the user gets a toast, a late next() is ignored and the rest of the chain stops.
Handlers that wait on purpose set timeout (milliseconds, a function of the configured handler, or null for no limit) and stop waiting when context.signal aborts:
import { abortableDelay } from '@stream-kit/plugin/action';
export const createWaitHandler = () =>
({
name: 'Wait for countdown',
timeout: (handler) => Number(getFieldValue(handler.fields, 'seconds')) * 1000 + 5_000,
execute: async (_action, handler, context, next) => {
const seconds = Number(getFieldValue(handler.fields, 'seconds'));
await abortableDelay(seconds * 1000, context.signal);
next();
}
}) satisfies HandlerDefinitionProps;@stream-kit/plugin/action also exports raceAbort(promise, signal), HandlerTimeoutError and DEFAULT_HANDLER_TIMEOUT_MS.
Command parsing
@stream-kit/core exposes helpers for chat command names and argument patterns:
import {
enrichChatMessageWithCommand,
extractCommandArgNames,
hasCommandArgPlaceholders,
matchCommandPattern,
parseCommand,
parseCommandMessage,
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.