Stream KitDocs

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 {
	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-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.

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: [/* … */]
			}
		]
	}
];
OptionDescription
collapsibleRender the section as a panel that opens and closes
defaultOpenInitial open state. Collapsible sections start closed (false) by default. A function is resolved once when the section first renders
badgeStatus badge in the header (label, optional variant), also visible while closed
iconIconify icon next to the title
fieldsFields 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

HookWhen it runs
onLoadAfter settings are hydrated from the plugin store during startup
onEnableWhen the plugin is enabled and its manifest dependencies are enabled (startup and manual toggle). Plugins start after their dependencies.
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

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

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

  • Paginator slices a reactive list on the client. Pass a getter, so the pages follow search, filters and deletes. page stays within the available pages, so deleting the last row of the last page moves you back one page.
  • Pagination renders the controls: previous/next, page numbers with … for collapsed ranges, and an optional summary on 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:

ComponentUse for
WidgetStatA single metric: label, value, optional total (3 / 12), hint and href
WidgetList + WidgetRowCompact 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:

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.

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 disable

Remote 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);
});
MemberDescription
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 / deleteModelMultilingual Whisper models, from Tiny to Large v3 Turbo
listInputDevicesMicrophones; 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 / stopOpen or close the microphone and load or unload the model
setContinuous(enabled)Transcribe every sentence (captions, wake words)
beginPushToTalk / endPushToTalkOne 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
onLevelMicrophone 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' }
		]
	});
}
MemberCreditsDescription
availabletrue when signed in on a plan that includes AI
generateText(options)2Short, 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 charactersTranslates up to 500 characters into targetLanguage (ISO 639-1); pass sourceLanguage when known. Charged by size, so continuous captions stay cheap
classify(options)1Picks 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()0Today'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:

codeMeaning
unauthenticated / subscription-requiredNo account, or no active plan that includes AI
not-allowedThe user didn't allow your plugin to use AI
quota-exceededOut of credits until usage.resetsAt
rate-limitedYour plugin made more than 60 calls in a minute or 4 at once
busyThe AI service is overloaded; try again later
invalid-inputInput outside the limits above (the message says which)
refusedThe AI declined the request
aborted / unavailable / failedCancelled, 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' }
});
MemberDescription
userPublic profile, or null when logged out
isAuthenticatedWhether 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 / 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 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();
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). Pass { notify: true } for the progress-bar toast.
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 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.

On this page