How to write, test, and ship a plugin. Aimed at JavaScript developers. Write some JS, run a command, get a plugin.
There's also a Python SDK (
owncast-plugin-py) with the same capabilities and a parallel API (snake_case fields,@plugindecorators /plugin.commands). This guide's examples are JavaScript. Theexamples/python/directory mirrors every example shown here. Either way you ship source, and the host runs it on a shared, embedded interpreter engine, so plugins don't bundle a runtime and packages are a few KB.
- Quick start
- Project layout
- The manifest
- Writing handlers
- Owncast APIs
- Permissions
- Limits
- Serving HTTP
- Realtime updates (Server-Sent Events)
- Admin pages
- Viewer authentication gates
- Action buttons
- Plugin-to-plugin events
- Testing
- Local dev server
- Deployment
- Recipes
- Tips
- Failure handling
npx create-owncast-plugin@latest my-plugin
cd my-plugin
npm install # one-time: fetches the test/serve binaries
npm run build # bundle src/plugin.js into the shippable plugin.js
npm test # build, then run scenarios from __tests__/
npm run serve # build, then host the plugin on http://localhost:8080
npm run package # build, then bundle into my-plugin.ocpkg for distributionEdit src/plugin.js to handle events and call Owncast APIs. Edit plugin.manifest.json to declare permissions and bot identities. When you're ready to ship, npm run package produces the .ocpkg you hand to a server admin.
my-plugin/
├── package.json
├── plugin.manifest.json # name, version, permissions, bots
├── icon.png # optional: shown in the admin plugin list
├── INSTRUCTIONS.md # optional: rendered as a tab in the admin
├── src/
│ └── plugin.js # your code
├── public/ # optional: served at /plugins/<slug>/...
│ └── index.html
├── assets/ # optional: host-read for manifest fields that
│ └── theme.css # inline content (styles, scripts, extraPageContent);
# never reachable through the plugin's URL space
└── __tests__/
└── plugin.test.js # scenarios
The two directories serve different purposes:
public/is the web-served root. Files under it are reachable at/plugins/<your-slug>/<path>while the plugin is enabled (and the manifest declareshttp.serve).assets/is the internal-only root. Files under it are read by the host for manifest fields that inline content (styles,scripts,extraPageContent). They are not reachable through the plugin's URL space.
Drop an icon.png at the root of your project (alongside plugin.manifest.json) and the packager will bundle it into the .ocpkg. The admin UI fetches it from /api/plugins/<slug>/icon and renders it in the plugin list and in the sidebar entry for any plugin that ships an admin page. No http.serve permission required: the host serves this path directly. There's no enforced size, but square images at 128×128 or so look clean at the rendered list (32×32) and sidebar (16×16) sizes. Plugins without an icon fall back to a generic puzzle-piece glyph.
Per-button icons on action buttons are separate. Bundle the image under public/ so the host serves it at the icon URL, then reference it from the icon field of an actions[] entry. See the Action buttons section.
Drop an INSTRUCTIONS.md at the root of your project (alongside plugin.manifest.json) and the packager bundles it into the .ocpkg. The admin UI renders it as markdown in an Instructions tab on the plugin's details page. Every plugin gets a details page, so this is the place for setup steps, configuration notes, or an explanation of why each permission is requested. The filename is fixed (INSTRUCTIONS.md) and the file is optional. No http.serve permission is required, since the host serves the content to the admin directly. Plugins without one show no Instructions tab.
{
"api": "1",
"name": "My Plugin",
"slug": "my-plugin",
"version": "0.1.0",
"description": "Short description for admins",
"permissions": ["chat.send", "storage.kv"]
}nameis the human-readable display name (admin lists, registry cards, default chat-bot identity). Any characters allowed.slugis the canonical identifier: URL prefix (/plugins/<slug>/), plugin-config namespace, on-disk filename, registry primary key. Lowercase letters, digits, and hyphens, starting with a letter, max 64 chars. Optional, the SDK auto-derives one fromnamewhen you omit it.bot.displayName(optional) overrides the chat-bot name when the plugin posts to chat. Defaults toname.permissionsis the list of capabilities your plugin needs (see below)category(optional) is the plugin's browse category in the plugin registry. One canonical slug from the table below. See Category.actions(optional) declares action buttons that appear under the viewer's stream. Requiresui.modify. See Action buttons.admin.pages(optional) declares admin-only routes the host auth-gates. See Admin pages.styles,scripts,extraPageContent(optional) inline plugin CSS, JavaScript, and HTML into the viewer page. All three requireui.modify(nohttp.serveneeded, the host reads fromassets/and inlines into existing responses). See Viewer-page injection.tabs(optional) adds tabs to the viewer page's tab row, each with its own HTML body. Requiresui.modify. See Viewer-page tabs.
The optional category field tells the plugin registry (and the admin's plugin browser) how to file your plugin for browse filtering. Pick the one canonical slug that best describes what the plugin does:
| Slug | Display name |
|---|---|
chat-bots |
Chat bots |
chat-filters |
Chat filters |
moderation |
Moderation |
authentication |
Authentication |
themes |
Themes |
overlays |
Overlays & widgets |
notifications |
Notifications |
integrations |
Integrations |
video |
Video & streaming |
analytics |
Analytics & stats |
games |
Games & fun |
admin-utilities |
Admin utilities |
examples |
Examples |
other |
Other |
Unknown values don't fail the build or the host load, the SDK packagers just warn, and the registry won't match the plugin to any category filter.
const { definePlugin, owncast, filter } = require("@owncast/plugin-sdk");
module.exports = definePlugin({
// Chat events
onChatMessage(msg) {
/* msg: ChatMessage */
},
onChatUserJoined(user) {
/* user: User */
},
onChatUserParted(user) {},
onChatUserRenamed(change) {
/* {user, previousName} */
},
onMessageModerated(event) {
/* {messageId, visible, moderator} */
},
// Stream lifecycle
onStreamStarted(info) {
/* {startedAt, title, summary} */
},
onStreamStopped(info) {
/* {stoppedAt} */
},
onStreamTitleChanged(change) {
/* {from, to} */
},
// Fediverse, engagement metadata
onFediverseFollow(event) {
/* {actor: {name, handle, url}} */
},
onFediverseLike(event) {
/* {actor, target: {url}} */
},
onFediverseRepost(event) {
/* {actor, target: {url}} */
},
onFediverseQuote(event) {
/* {actor, target: {url}}; target is the locally authored quoted post */
},
// Fediverse, inbound posts (with content)
onFediverseMention(post) {
/* FediverseInboundPost, see below */
},
onFediverseReply(post) {
/* same shape; inReplyTo set */
},
// Every verified inbound activity, including activities handled above
onFediverse(activity) {
/* raw ActivityPub JSON object */
},
// Filter chain (sequential, can mutate or drop)
filterChatMessage(msg) {
return filter.pass();
},
filterPriority: 100, // optional; lower = earlier in chain
// HTTP endpoint (any path under /plugins/<your-name>/...)
onHttpRequest(req) {
return { status: 200, body: "ok" };
},
// Plugin-emitted custom events
on: {
"another-plugin.something"(payload) {
/* ... */
},
},
});Only define the handlers you actually need. Missing handlers = no subscription.
All seven Fediverse handlers require fediverse.inbound: onFediverse, onFediverseFollow, onFediverseLike, onFediverseRepost, onFediverseQuote, onFediverseMention, and onFediverseReply. These are internal plugin event subscriptions, not external HTTP webhooks. fediverse.post is a separate permission for publishing outbound posts.
onChatMessage(msg) and filterChatMessage(msg) receive a ChatMessage:
interface ChatMessage {
id: string;
user?: User; // full sender identity (see below); absent if no account
clientId?: number; // originating connection; pass to chat.sendTo / replyTo
body: string; // the raw text the user typed (not HTML-rendered)
timestamp: string; // ISO-8601, nanosecond precision
}
interface User {
id: string;
displayName: string;
displayColor: number; // index into the instance's user-color palette, not a literal color
previousNames?: string[];
createdAt?: string; // ISO-8601
disabledAt?: string; // ISO-8601 if banned, omitted otherwise
isBot?: boolean;
isAuthenticated?: boolean;
scopes?: string[]; // e.g. ["MODERATOR"]
}user carries the full sender identity, so do per-user state off the stable
user.id and gate moderator-only commands on user.scopes.includes("MODERATOR").
Don't match on the display name, which isn't unique or stable. To reply
privately to whoever sent a message, pass msg (or msg.clientId) to
owncast.chat.replyTo.
useris always aUserobject, the same shape on theonChatMessageevent, on the messages returned byowncast.chat.history(), and in the dev server. It isundefinedonly for the rare message with no associated account.
onFediverseFollow receives {actor}. onFediverseLike, onFediverseRepost, and onFediverseQuote receive {actor, target}. actor uses the SDK's FediverseActor shape. target is {url: string}. For a quote, it identifies the locally authored post that the remote actor quoted.
onFediverse receives the verified inbound ActivityPub activity after the host validates the HTTP signature and actor origin. In JavaScript, the payload is the raw JSON object. It fires in addition to a specialized handler when one applies. It also fires for verified activity types that have no specialized handler.
Python exposes the same hooks as @plugin.on_fediverse, @plugin.on_fediverse_follow, @plugin.on_fediverse_like, @plugin.on_fediverse_repost, @plugin.on_fediverse_quote, @plugin.on_fediverse_mention, and @plugin.on_fediverse_reply. Python notify payloads are non-subscriptable _Obj attribute views. Read normal fields as attributes, or use .raw for the underlying dictionary and keys that are not valid Python attributes, such as payload.raw["@context"].
onFediverseMention and onFediverseReply carry a FediverseInboundPost. The host accepts these specialized events only from a verified public Create(Note) that either mentions the local Fediverse account or replies to a locally authored post:
interface FediverseInboundPost {
actor: { name: string; handle: string; url?: string; image?: string };
content: string; // rendered HTML from the source instance
contentText: string; // plain-text version (HTML stripped), usually what you want
url: string; // permalink on the source instance
postedAt: string; // ISO-8601
inReplyTo?: string; // parent post URL, set when this is a reply
attachments?: { url: string; mediaType: string; alt?: string }[];
language?: string;
}actor.handle is the fully-qualified fediverse address (e.g. @alice@fediverse.example). Use contentText for analysis or display. Use content if you need to render the original HTML formatting.
filterChatMessage(msg) returns one of:
filter.pass(), let the message through unchangedfilter.modify(payload), replace the message (subsequent filters see your version)filter.drop(reason), drop the message, no later filters or notifications see it
The manifest must declare the chat.filter permission, otherwise the host refuses to load the plugin at register time. Reading or rewriting every chat message is a meaningful side-effect, so the admin has to see the permission to grant it.
Filter errors are treated as filter.pass() automatically, a buggy plugin can never block chat. Set filterPriority (lower = earlier) when order matters.
interface IncomingHttpRequest {
method: string;
path: string; // relative to /plugins/<slug>/
query: Record<string, string>;
headers: Record<string, string>;
body: string;
remoteAddr: string;
authenticated: boolean; // came from an authenticated Owncast admin
user?: User; // the chat user the request came from, when known
}
interface OutgoingHttpResponse {
status?: number; // default 200
headers?: Record<string, string>;
body?: string;
}Endpoints are public by default. Gate admin features with req.authenticated.
req.user is the chat user the request came from, if Owncast could identify
one. The host resolves it from the visitor's chat identity cookie, which is set
when they register or connect to chat, so it is available on normal viewer
requests such as the page behind an action button. You don't need an access
token in the URL, and you don't do the lookup yourself.
The field is optional. It's set when the request came from a known chat user
and is undefined otherwise, so check for it before reading it. Your plugin
never receives the raw access token: the Cookie header and any accessToken
query parameter are removed from req.headers and req.query before your
handler runs. The whoami example shows the whole flow.
Each method requires the matching permission in your manifest:
| API | Requires |
|---|---|
owncast.chat.send(text) |
chat.send |
owncast.chat.sendAction(text) (italic style) |
chat.send |
owncast.chat.sendTo(clientId, text), private message |
chat.send |
owncast.chat.replyTo(msg, text), whisper back to a message's sender |
chat.send |
owncast.chat.history(limit?), recent messages |
chat.history |
owncast.chat.clients(), connected chat clients |
chat.history |
owncast.chat.deleteMessage(messageId) |
chat.moderate |
owncast.chat.kick(clientId) |
chat.moderate |
owncast.users.list() / .get(id) |
users.read |
owncast.users.setEnabled(id, enabled, reason?) |
users.moderate |
owncast.users.banIP(ip) |
users.moderate |
owncast.users.register({authId, displayName}), find-or-create a viewer identity |
users.register |
owncast.auth.grantSession({userId, ttl?}) / owncast.auth.endSession() |
auth.gate |
owncast.kv.get(key) / .set(key, value) (+ .getJSON / .setJSON) |
storage.kv |
owncast.storage.upload(name, bytes), returns {url} |
storage.upload |
owncast.fs.read/readText/write/list/delete/exists(...), sandboxed disk |
storage.fs |
owncast.http.fetch(url, opts?) |
network.fetch |
owncast.events.emit(eventType, payload) |
events.emit |
owncast.stream.current(), live stream state |
server.read |
owncast.stream.broadcaster(), inbound encode telemetry (read-only) |
server.read |
owncast.server.info(), server name, version, etc. |
server.read |
owncast.server.socials(), [{platform, url, icon}] |
server.read |
owncast.server.emotes(), custom chat emotes [{name, url}] |
server.read |
owncast.server.federation(), {enabled, username, isPrivate} |
server.read |
owncast.server.tags(), [string] |
server.read |
owncast.videoConfig.read(), {latencyLevel, codec, variants} |
videoconfig.read |
owncast.videoConfig.write({latencyLevel?, codec?, variants?}), partial update |
videoconfig.write |
owncast.notifications.discord(text) |
notifications.send |
owncast.notifications.browserPush({title, body, url?}) |
notifications.send |
owncast.notifications.fediverse({type, body, image?, link?}) |
notifications.send |
owncast.fediverse.post(text), public post to the fediverse |
fediverse.post |
onHttpRequest + static files from public/ |
http.serve |
owncast.sse.send(channel, event, data), push to browsers |
http.sse |
owncast.timer.setTimeout/setInterval/clear(...), schedule callbacks |
none (ambient) |
owncast.config.get(key, fallback?), read manifest-declared config |
none (ambient) |
Calling an API without its permission throws a clear error.
Every plugin has exactly one chat identity, the auto-bot Owncast provisions when your plugin is installed. The display name is your plugin's name (e.g. echo-bot), with IsBot: true. owncast.chat.send(text) and owncast.chat.sendAction(text) both post as this identity, through Owncast's normal chat pipeline (filters, rate limits, persistence, moderation, same as any user).
Text is HTML-escaped on display.
owncast.chat.send/sendActiontake plain text, and the chat client renders it as HTML, so characters like",<, and&come back entity-encoded ("Live"displays via"Live"). That's expected, so don't pass markup expecting it to render.owncast.chat.system(body)is the exception: its body is rendered as HTML, so you must escape any untrusted content you put in it yourself.
If you need multiple chat personas, ship multiple plugins. One identity per plugin keeps the trust boundary clear: admins see one chat user per granted plugin, and there's no allowlist machinery to forget or bypass. Plugins cannot post under arbitrary names or impersonate real users.
A chat message carries the sender's clientId, so to whisper a confirmation,
cooldown, or usage notice privately back to whoever ran a command, pass the
message straight to owncast.chat.replyTo:
filterChatMessage(msg) {
if (onCooldown(msg.user?.id)) {
// replyTo returns false if the sender's connection is gone; fall back to public.
if (!owncast.chat.replyTo(msg, "slow down, try again in a few seconds")) {
owncast.chat.send("slow down, try again in a few seconds");
}
return filter.drop("cooldown");
}
return filter.pass();
}replyTo(msg, text) is sugar over sendTo(msg.clientId, text). Pass a bare
clientId if that's all you have.
Declare a command table instead of parsing every chat message yourself. Command tables support aliases, parsed arguments, moderator gates, and per-user cooldowns:
const { definePlugin } = require("@owncast/plugin-sdk");
module.exports = definePlugin({
commandPrefix: "!", // optional, default "!"
commands: {
uptime: {
description: "How long we've been live",
run: (ctx) => ctx.reply("we've been live a while!"),
},
so: {
description: "Shout out a viewer",
usage: "!so <name>",
aliases: ["shoutout"],
cooldownMs: 10_000,
run: (ctx) => ctx.reply(`go follow ${ctx.args[0] || "someone cool"}`),
},
clear: {
description: "Clear the chat",
modOnly: true,
run: (ctx) => ctx.replyPrivately("done"),
},
},
});Each run(ctx) receives
{ msg, user, command, invokedAs, args, argString, reply, replyPrivately }.
command is the canonical declaration. invokedAs is the name or alias the
sender typed. reply posts publicly and replyPrivately whispers to the
sender.
Python uses the same core behavior through plugin.commands(...):
from owncast_plugin import plugin
plugin.commands({
"uptime": {
"description": "How long we've been live",
"run": lambda ctx: ctx.reply("we've been live a while!"),
},
"so": {
"aliases": ["shoutout"],
"cooldown_ms": 10_000,
"run": lambda ctx: ctx.reply(
f"go follow {ctx.args[0] if ctx.args else 'someone cool'}"
),
},
})Command behavior:
- Duplicate commands are allowed. Every matching plugin runs.
- Unknown commands produce no response.
- Non-moderators do not invoke
modOnlycommands. - Cooldown-limited invocations produce no response.
- Command messages remain ordinary chat messages. A separate
onChatMessageor@plugin.on_chat_messagehandler still receives them. - Chat filters run first. A filtered-out message cannot execute a command.
No permission is required to declare or receive a command. The handler still
needs the permission for each action it performs, such as chat.send,
chat.moderate, or network.fetch.
Owncast posts an aggregated command list for !help and its !commands alias.
The message remains ordinary chat, and plugins may also declare or respond to
either command. The built-in response does not block additional plugin
responses. modOnly commands are hidden from non-moderators.
For a single fixed command, checking msg.body in onChatMessage is still
valid. See the ip-bot and relay examples. Hand-rolled commands do not appear
in the built-in help listing.
| Permission | Grants |
|---|---|
chat.send |
owncast.chat.send, .sendAction, .sendTo |
chat.history |
owncast.chat.history, .clients |
chat.moderate |
owncast.chat.deleteMessage, .kick |
chat.filter |
Subscribe to filterChatMessage (read, modify, or drop every chat message). Required for any plugin that declares the handler. |
users.read |
owncast.users.list, .get |
users.moderate |
owncast.users.setEnabled, .banIP |
users.register |
owncast.users.register, find-or-create an authenticated Owncast user for an external identity. The host namespaces authId by your slug, so plugins can't collide on or impersonate each other's users. |
auth.gate |
Be the site's viewer-authentication gate: owncast.auth.grantSession / .endSession plus the onAuthCheck hook. While enabled, the host blocks every route for unauthenticated visitors. Only one gate plugin can be enabled at a time. See Viewer authentication gates. |
storage.kv |
Per-plugin namespaced key/value store |
storage.upload |
owncast.storage.upload, upload files, get a public URL |
storage.fs |
owncast.fs.*, private sandboxed disk under data/plugin-data/<slug>/ (server-side only, never served over HTTP) |
network.fetch |
Outbound HTTP, also requires network.allowedHosts (see below) |
events.emit |
Emit custom events for other plugins to subscribe to |
http.serve |
Serve HTTP at /plugins/<your-name>/* |
http.sse |
Push realtime events to browsers via owncast.sse.send + the /_sse/ endpoint |
server.read |
Read stream state, server config, and read-only broadcast telemetry (stream.broadcaster) |
videoconfig.read |
owncast.videoConfig.read(), read the output/transcoding config |
videoconfig.write |
owncast.videoConfig.write(), change video config, high-trust. Changes apply on the next stream start (the host does not restart a live stream). |
notifications.send |
owncast.notifications.discord, .browserPush |
fediverse.inbound |
Subscribe to fediverse.activity, fediverse.follow, fediverse.like, fediverse.repost, fediverse.quote, fediverse.mention, and fediverse.reply through onFediverse and the six specialized handlers. Required for any plugin that declares one of those handlers. |
fediverse.post |
owncast.fediverse.post(text), high-trust (posts under the streamer's handle), admin should grant sparingly. |
ui.modify |
Place UI inside Owncast's own chrome. Required for manifest.actions, manifest.styles, manifest.scripts, and manifest.extraPageContent. |
Declare only what you need. Admins reviewing your manifest before install make trust decisions based on declared permissions.
network.fetch is gated by an explicit allowlist of hostnames. The host rejects the load if network.fetch is granted without a corresponding network.allowedHosts entry:
{
"permissions": ["network.fetch"],
"network": {
"allowedHosts": ["api.discord.com", "*.weather.com"]
}
}Entries are hostname globs, bare hostnames match exactly. * is a wildcard segment. The wildcard "*" matches any host but must be written explicitly ("network": { "allowedHosts": ["*"] }) so admins reviewing the manifest see the scope they're granting. Most plugins should list the specific hosts they actually call.
The host enforces these caps per plugin. They're generous for normal use. Size pages, APIs, and JSON payloads against them so you don't get truncated or timed out.
| Limit | Value | Applies to |
|---|---|---|
| Wasm linear memory | 64 MiB | the whole plugin instance |
on_filter (chat filter) runtime |
50 ms | each filterChatMessage call |
on_event (notification) runtime |
500 ms | each event handler (onChatMessage, onStreamStarted, onTick, …) |
on_http_request runtime |
5 s | each HTTP handler call |
| Hard per-call ceiling | 10 s | any single export call (backstop above the above) |
register() output |
256 KiB | the subscription/manifest JSON your build emits |
on_filter output |
1 MiB | the (modified) message a filter returns |
| HTTP request body delivered to your handler | 1 MB | inbound onHttpRequest body |
| HTTP response body | 10 MB | what onHttpRequest returns |
on_http_request envelope output |
12 MiB | the full encoded response envelope |
| Pending timers | 64 | owncast.timer.setTimeout/setInterval outstanding at once |
| Timer delay | 100 ms to 24 h | clamped into this range |
| SSE connections | 64 | concurrent browser clients on your event stream |
owncast.kv values have no hard size cap, but it's a config/state store, not a blob store, so keep values small (use storage.upload or storage.fs for large data). Timeouts mean a handler that blocks (a slow owncast.http.fetch, a tight loop) is cancelled, so keep event/filter work quick and push slow work elsewhere.
Anything in your public/ directory is served at /plugins/<your-name>/.... Requests that don't match a file fall through to your onHttpRequest handler. The separate assets/ directory holds files the host reads internally for manifest-inlined content (styles, scripts, extraPageContent) and is never reachable through the plugin's URL space.
Host-version note.
public/-as-web-root is the current convention and what every Owncast host from the 0.4.x line onward serves. The standaloneowncast-plugin-servev0.4.0 release binary predates it and serves static files fromassets/instead, so a plugin authored per these docs (pages inpublic/) shows 404s for its pages on that one older binary. If you must support it, bundle the same files under both directories (for example anassets/topublic/symlink). Newer hosts and the bundled dev server (owncast-plugin serve) servepublic/directly, so this only affects that specific downloadable binary.
my-plugin/
└── public/
├── index.html → /plugins/my-plugin/index.html (and /plugins/my-plugin/)
└── style.css → /plugins/my-plugin/style.css
A request to /plugins/my-plugin/ serves public/index.html automatically.
For dynamic endpoints (JSON APIs, webhooks, etc.) write an onHttpRequest. Path traversal is blocked, response headers are filtered through an allowlist (allowed: Content-Type, Cache-Control, Set-Cookie, Location, ETag, Last-Modified, Vary, Link, and CORS headers, with host-owned things like Server, CSP, and HSTS blocked), and body sizes are capped at 1 MB request / 10 MB response. Cookies you set default to a Path scoped to your plugin's namespace.
For pushing live updates to a browser, an overlay that reacts to chat, a dashboard that ticks viewer counts, an alert widget, declare http.sse and use owncast.sse.send.
You do not open or hold the connection yourself. Your onHttpRequest handler can't stream: each call is a single buffered request/response. Instead, the host owns the long-lived connection and exposes a ready-made endpoint at /plugins/<your-slug>/_sse/<channel>. Your plugin just pushes. The host fans each message out to every connected browser.
Plugin side, push whenever you have something to send (from an event handler, an HTTP handler, anywhere):
// src/plugin.js
export function onChatMessage(msg) {
// Notify every browser watching the "overlay" stream.
owncast.sse.send("overlay", "chat", {
from: msg.user?.displayName,
body: msg.body,
});
}send(channel, event, data):
channel, which stream to push to. Browsers subscribe per channel, so you can run several independent streams ("overlay","admin-stats") from one plugin. Use""for a single default channel.event, the event name the browser listens for (addEventListener("chat", …)). Pass""for the browser's defaultmessageevent.data, the payload. Strings are sent as-is, anything else is JSON-encoded for you.
Sends are fire-and-forget: the call returns immediately and never blocks, even if no one is connected or a client is slow (slow clients drop frames rather than stall your plugin).
Browser side, connect with the standard EventSource API, no library needed:
<!-- public/index.html, served at /plugins/my-plugin/ -->
<script>
const events = new EventSource("/plugins/my-plugin/_sse/overlay");
events.addEventListener("chat", (e) => {
const { from, body } = JSON.parse(e.data);
document.getElementById("feed").textContent = `${from}: ${body}`;
});
</script>Notes:
- Up to 64 simultaneous connections per plugin. Over that the endpoint returns 503.
EventSourcereconnects automatically. - If the channel matches one of your
admin.pages[]globs it's auth-gated like any admin route, handy for an admin-only stats stream. - The endpoint is host-owned and reserved: your
onHttpRequestnever sees/_sse/...requests, and you can't serve your own route there.
When you declare http.sse, the host tells you whenever a browser opens or closes one of your streams, so you can track who's connected:
const present = new Map(); // connectionId -> user (or undefined)
definePlugin({
onSseConnect({ channel, connectionId, user }) {
present.set(connectionId, user); // user is undefined for anonymous viewers
},
onSseDisconnect({ connectionId }) {
present.delete(connectionId);
},
});The host resolves the connecting chat user from their identity cookie, the same user shape your HTTP handlers get on req.user, and it's omitted when the viewer hasn't joined chat. connectionId is unique per connection, so a disconnect pairs with its connect and one user open in several tabs counts as several connections. These arrive on the normal event path (no extra permission beyond http.sse), and a connect/disconnect you don't handle is ignored.
There is no setTimeout in the sandbox: a handler runs to completion and then your plugin is idle until the host calls it again, so nothing can run "later" on its own. Two host-driven mechanisms cover delayed and periodic work. Neither needs a permission.
owncast.timer schedules a callback the host runs for you later, in this same instance:
// Send a message 30 seconds from now:
const id = owncast.timer.setTimeout(() => owncast.chat.send("starting now!"), 30_000);
// Repeat until cleared:
const ping = owncast.timer.setInterval(() => owncast.chat.send("still live"), 60_000);
owncast.timer.clear(id); // cancel either kindsetTimeout/setInterval return an id for clear(). Very small delays are clamped up by the host, there's a per-plugin cap on pending timers (scheduling past it throws), and an interval's next run is scheduled only after the previous one returns, so a slow callback can't pile up. Timers are in-memory: they do not survive a plugin reload or a host restart. For "remind me even if Owncast restarts" you'd persist the target time yourself (e.g. in storage.kv) and re-arm on load.
onTick({ now }) fires once a second for open-ended periodic work. Define it to opt in. now is the host wall-clock time in unix milliseconds.
definePlugin({
onTick({ now }) {
// runs every ~1s while the plugin is enabled
},
});Date works normally for formatting and arithmetic, and the clock is real: Date.now() and new Date() return the actual current time, and performance.now() is a real monotonic clock. (onTick/timer payloads also carry the host time, handy if you don't want to call Date yourself.) setTimeout/setInterval as JS globals do not exist, use owncast.timer instead.
Declare admin-configurable settings under config in the manifest, each with a
type, a default, and a description:
{
"config": {
"greeting": { "type": "string", "default": "welcome!", "description": "First-join message" },
"cooldownMs": { "type": "number", "default": 2000, "description": "Per-user command cooldown" },
"modOnly": { "type": "boolean", "default": false, "description": "Restrict to moderators" }
}
}Read them from plugin code with owncast.config.get(key), which returns the
admin-set value when present and the declared default otherwise, already parsed
to the declared type:
const cooldown = owncast.config.get("cooldownMs"); // number, e.g. 2000
const greeting = owncast.config.get("greeting", "hi"); // 2nd arg is a fallback for unknown keysconfig.get is ambient (no permission). Reading an undeclared key returns the
fallback (or undefined). This is the recommended way to expose simple knobs.
Prefer it over building a bespoke settings page and KV plumbing.
Plugins can register pages that appear in the Owncast admin UI for configuration. Declare them in the manifest:
{
"permissions": ["http.serve"],
"admin": {
"pages": [
{ "title": "Settings", "path": "/admin", "icon": "gear" },
{ "title": "Settings", "path": "/admin/*" }
]
}
}pathis a glob (e.g."/admin","/admin/*"). Requests under/plugins/<your-slug>/<path>that match any declared glob are auth-gated by the host, unauthenticated requests get401before your plugin code ever runs.- Owncast's admin renders each declared page as a tab inside
/admin/plugins/configure?id=<your-slug>, embedded as an iframe pointed at/plugins/<your-slug>/<path>. Each plugin gets its own bookmarkable URL plus a sidebar entry under Plugins in the admin nav. - Both static assets and dynamic endpoints under matched paths are auth-gated. You don't have to check
req.authenticatedyourself. - The host auto-injects an admin-themed stylesheet (
/styles/admin/plugin-iframe.css) into HTML responses on admin paths so plain<input>/<button>controls match Owncast's look without you needing to ship CSS. Plugins that prefer their own styling can layer on top. - The iframe is sandboxed but permits what an admin page normally needs: your scripts, form submits, same-origin
fetchto your own endpoints, popups, file downloads (a blob/data-URL<a download>clicked from script), andconfirm()/alert()/prompt()dialogs. If a browser feature seems silently blocked, suspect the iframe sandbox first.
Author flow:
- Put admin HTML/CSS/JS in
public/admin/index.html(and friends) - Expose admin APIs via
onHttpRequestat/admin/api/... - Declare both globs (or just
"/admin/*") inmanifest.admin.pages[].path - Visit
/admin/plugins/configure?id=<your-slug>in the admin UI (or/plugins/<your-slug>/admin/directly). Owncast uses your existing admin login to gate the page. No extra prompt.
A plugin holding the auth.gate permission can become the site's login wall: until a visitor authenticates through it, the host blocks the whole server — the watch page, the HLS video, chat, and the API. The plugin renders the login flow and decides who gets in; the host owns the signed session cookie end to end (your code never sees it). Only one auth.gate plugin can be enabled at a time.
The flow (see examples/js/github-auth for a complete OAuth version):
- An unauthenticated visitor requests any page. The host redirects them to your plugin's index at
/plugins/<slug>/?return_to=<original path>. - Your
onHttpRequestrenders a login screen and runs whatever proves the visitor's identity and entitlement: an OAuth round-trip with a provider, a lookup against a webhook-maintained member list, etc. - Once satisfied, name the visitor and grant the session:
const { userId } = owncast.users.register({ authId: "provider:1234", displayName: "Jo" });
owncast.auth.grantSession({ userId }); // the host attaches the cookie to this response
return { status: 302, headers: { Location: returnTo } };- For logout, call
owncast.auth.endSession()and redirect.
users.register finds-or-creates an authenticated Owncast user for an external identity (the host namespaces authId by your slug). grantSession/endSession are only meaningful inside onHttpRequest, where the host attaches or clears the cookie on the response after your handler returns.
Define onAuthCheck(req) to re-validate a session whenever a signed-in viewer loads the main page. This is how a gate notices lapsed memberships without needing webhooks:
onAuthCheck(req) {
// req.user is the host-resolved viewer identity (same shape as onHttpRequest's req.user)
return stillEntitled(req.user.id) ? authCheck.ok() : authCheck.deny();
}Return authCheck.ok(), authCheck.deny() (the host clears the session and bounces the viewer to your login screen), or authCheck.refresh({ ttl? }) (keep the session and re-issue the cookie for sliding expiry). Errors fail closed, as a deny.
Gate-specific things to know:
req.usercarries no external identity. Store your own mapping at registration time (owncast.kv.set("member:" + userId, externalId)) and look it up inonAuthCheck.- Use a stable external id in
authId(a numeric account id), never a username or email that can change. - Your own routes stay reachable through the gate — visitors must be able to reach the login screen — which also means inbound webhooks to
/plugins/<slug>/...keep working while the gate is up./adminis likewise exempt, so an admin can always fix or disable a misconfigured gate. - Fail closed while unconfigured: refuse to grant sessions until your config values are set.
- Testing: the
authCheckscenario step drivesonAuthCheckdirectly (see Step types).
Owncast surfaces a row of "external action" buttons in its UI, clickable entries that either open a URL (in a modal or new tab) or render raw HTML. Plugins can contribute their own. While the plugin is enabled, the host merges its action entries into the list Owncast already shows. When disabled, they disappear.
Declare them in the manifest:
{
"permissions": ["ui.modify", "http.serve"],
"actions": [
{
"title": "Chat Overlay",
"description": "Open the live chat overlay",
"url": "/",
"icon": "/plugins/my-plugin/icon.svg",
"color": "#3b82f6"
},
{
"title": "Help",
"html": "<p>Visit our <a href='https://example.com/docs'>docs</a>.</p>"
},
{
"title": "Issue tracker",
"url": "https://github.com/example/my-plugin/issues",
"openExternally": true
}
]
}ui.modifyis required for any plugin that declaresactions, because action buttons place UI inside Owncast's own chrome. The host rejects the load otherwise so it's visible at install time that the plugin extends the host UI.- Exactly one of
urlorhtmlis required per entry. - Relative URLs auto-prefix.
"url": "/"becomes/plugins/my-plugin/at load time."/stats"becomes/plugins/my-plugin/stats. Saves you from hard-coding your plugin name in your own manifest. - Absolute
https://...URLs are accepted unchanged, use them for external links (setopenExternally: trueto open in a new tab). - If your URL resolves into your own
/plugins/<slug>/namespace, you must also declarehttp.serveso the page actually serves. The host rejects the load with a clear error otherwise. - You can't point at another plugin's namespace (
/plugins/some-other-plugin/...), the host rejects that at load to catch typos. - Per-button icons (
iconfield) follow the same rules asurl. A relative path like"/star.png"auto-prefixes to/plugins/my-plugin/star.png, so ship the image underpublic/andhttp.servewill route it. Absolutehttps://cdn.example.com/...URLs pass through unchanged (use these for external icons that don't needhttp.serve). Cross-plugin icon paths are rejected just like cross-plugin URLs.
This pairs naturally with http.serve: ship a UI under public/ and declare an action button that opens it.
A plugin can append more action buttons on top of manifest.actions without a reload:
owncast.actions.add({
title: "Donate",
url: "https://example.com/donate",
openExternally: true,
});
// or pass an array to add several at once
owncast.actions.add([
{ title: "Tip", url: "https://example.com/tip", openExternally: true },
{ title: "Schedule", html: "<p>Weekdays 8pm UTC.</p>" },
]);
// remove every runtime addition; manifest.actions remain
owncast.actions.clear();The host validates each entry with the same rules as manifest.actions (title required, exactly one of url / html, relative URLs and icons auto-prefixed, cross-plugin URLs/icons rejected) and persists the result in the plugin's config so the additions survive a reload. The next viewer /api/config request returns manifest.actions ++ the runtime list. Requires ui.modify.
A common pattern is an admin page that lets the streamer add a custom button (label + URL) on top of the plugin's defaults. The action-buttons example in the SDK ships a working version.
Three manifest fields let a plugin contribute content directly to the viewer page: CSS, JavaScript, and a block of HTML. The host inlines plugin contributions into the same response slots Owncast already uses for the admin's custom CSS, custom JS, and extra page content, so a viewer loads one stylesheet, one script, and one extra-content block regardless of how many plugins contributed.
| Manifest field | Inlined into | Required permission | Notes |
|---|---|---|---|
styles |
/api/config → customStyles |
ui.modify |
must end .css |
scripts |
/customjavascript |
ui.modify |
must end .js |
extraPageContent |
/api/config → extraPageContent |
ui.modify |
static or dynamic |
Each also has a dynamic form computed at request time by a handler instead of a static file. extraPageContent uses onPageContent (below). CSS and JavaScript use the onPageStyles and onPageScripts handlers, which need no manifest entry at all. The host calls them once per /api/config for any plugin holding ui.modify, and a plugin opts in by exporting the handler. Their output lands in the same customStyles / /customjavascript slots, appended after any static styles / scripts contributions.
{
"permissions": ["ui.modify"],
"styles": ["theme.css", "overrides.css"]
}Bundle the CSS files under assets/ and reference them by path. The host strips the plugin prefix, reads each file's bytes, and concatenates them in front of a /* plugin: <slug> — <file> */ delimiter so a reader can attribute a rule back to whichever plugin shipped it. Disabling the plugin drops its contribution.
Path rules match action-button URLs:
- Bare
"theme.css"and single-slash"/theme.css"resolve to/plugins/<slug>/theme.css. - Fully qualified
/plugins/<slug>/...paths pass through. - Paths in another plugin's namespace,
http(s)://URLs, or non-.cssextensions are rejected at load.
Relative url(...) references inside the CSS resolve against the viewer page, not against the plugin's namespace. Use an absolute path like /plugins/<slug>/logo.png when referencing bundled images or fonts.
When the CSS depends on plugin state, like a theme the admin selected or a value in the KV store, return it from onPageStyles instead of (or in addition to) shipping a static file. No manifest field is involved. Export the handler and declare ui.modify.
module.exports = definePlugin({
onPageStyles() {
const theme = owncast.kv.get("theme"); // e.g. "midnight"
if (!theme) return ""; // contribute nothing
return `:root { --theme-color-action: ${accentFor(theme)}; }`;
},
});The returned string is appended to customStyles after any static styles files (so a later rule wins the cascade), preceded by a /* plugin: <slug> — dynamic */ delimiter. The call is global. It takes no per-viewer argument, which keeps /api/config cacheable. Return "" to contribute nothing on this request. Python exposes the same hook as the bare decorator @plugin.on_page_styles.
{
"permissions": ["ui.modify"],
"scripts": ["client.js"]
}Same path and permission rules as styles, applied to .js files. The host prefixes each contribution with // plugin: <slug> — <file>.
Two things to keep in mind about execution:
- Every plugin's script runs in the viewer page's window, sharing the global scope with the admin's customJavascript and every other plugin. Wrap your script in an IIFE so top-level declarations don't collide.
- The host wraps each plugin's contribution in its own
try/catch, so a runtime error throws to the browser console (owncast plugin <slug> script error:) without aborting other plugins' scripts. A syntax error is not isolated. It fails to parse the shared bundle before anytryruns, so still ship valid JS.
The script counterpart to onPageStyles: return JavaScript computed at request time from onPageScripts. No manifest field is involved. Export the handler and declare ui.modify.
module.exports = definePlugin({
onPageScripts() {
const theme = owncast.kv.get("theme");
if (!theme) return "";
return `(function () { document.documentElement.dataset.theme = ${JSON.stringify(theme)}; })();`;
},
});The return is appended to /customjavascript after any static scripts, wrapped in the same per-plugin try/catch, and runs in the shared viewer window, so the IIFE and escaping advice above applies. The call is global (no per-viewer argument). Python exposes it as @plugin.on_page_scripts.
extraPageContent is an object with a required slug and an optional content file:
{
"permissions": ["ui.modify"],
"extraPageContent": { "slug": "banner", "content": "content.html" }
}Static (content present): the host reads the file from assets/ and inlines it at the top of the viewer's extra-content block, above whatever the admin has configured. Path rules match styles and scripts but the entry has to end in .html. http.serve is not required.
Dynamic (content absent): the host calls onPageContent({ slug, user? }) at /api/config time and inlines the returned HTML string. user is the requesting viewer's chat identity when available.
module.exports = definePlugin({
onPageContent({ slug, user }) {
if (slug === "banner") {
const name = user?.displayName ?? "visitor";
return `<aside>Welcome, ${name}!</aside>`;
}
return "";
},
});The admin's extra page content goes through the markdown processor, but plugin HTML does not. Tags and attributes pass through as written, so escape any untrusted strings you embed.
Plugins can add tabs to the viewer page's tab row (alongside the built-in About and Followers tabs) with manifest.tabs[]. Each entry requires a title and a slug, and content is optional:
{
"permissions": ["ui.modify"],
"tabs": [
{ "title": "Music", "slug": "music", "content": "music.html" },
{ "title": "Stream Info", "slug": "stream-info" }
]
}slug: required. Stable identifier, unique within the plugin's tabs. Passed toonTabContentso the handler knows which tab to render. Lowercase letters, digits, hyphens.title: required. The label shown on the tab. Keep it short (~16 characters max for mobile).content: optional. Relative path to a static HTML file inassets/. When omitted, the host callsonTabContent.
Static (content present): the host reads the file from assets/ and inlines it as the tab body. Path rules match extraPageContent.content.
Dynamic (content absent): the host calls onTabContent({ slug, user? }) and inlines the returned HTML string.
module.exports = definePlugin({
onTabContent({ slug, user }) {
if (slug === "stream-info") {
const stream = owncast.stream.current();
return stream.online
? `<p>Live: ${stream.title} (${stream.viewers} viewers)</p>`
: `<p>Offline</p>`;
}
return "";
},
});Requires ui.modify. http.serve is not required, because the host inlines the result and nothing is served at a URL. Add whatever data permissions (server.read, chat.history, etc.) your handler actually calls.
The tabs-demo example ships two static tabs. page-content-demo demonstrates dynamic rendering with Mustache templates and server.read data.
Plugins compose by emitting custom events:
// Emitter (needs events.emit permission)
owncast.events.emit("my-plugin.thing-happened", { id: 123 });
// Subscriber
on: {
"my-plugin.thing-happened"(payload) { /* ... */ }
}Use <your-plugin>.<event> namespacing. Event names are arbitrary strings.
Tests live in __tests__/*.test.js. They drive your built plugin through the real Owncast plugin runtime with mocked side effects, passing tests guarantee the same behavior in production.
Before any scenario runs, the test runner load-checks the plugin: the same
install-time path a real Owncast server runs, covering manifest validity, a
healthy register(), and permission-gated subscriptions (a chat filter
without chat.filter, or a fediverse handler without fediverse.inbound,
fails right there with the same error the server gives at install). The check
runs even when a plugin ships no tests, and package runs it too — a plugin
that would be rejected at install refuses to package.
const { runScenarios } = require("@owncast/plugin-sdk/testing");
runScenarios([
{
name: "echoes the message",
events: [
{
event: "chat.message.received",
payload: { user: "alice", body: "hi" },
},
],
expect: {
chatSends: ["alice said: hi"],
},
},
]);npm test builds your plugin (bundling src/plugin.{js,ts} into <slug>.js), then runs node __tests__/*.test.js. Each scenario is a { name, given?, events, expect? } object, the same data model as a JSON scenario file, but in JS you can build the array with loops, helpers, fixtures, or computed payloads.
If you prefer raw JSON, drop __tests__/*.test.json files in instead and invoke the runner with owncast-plugin test. The data model is identical, and the host binary that runs them is the same. Pick whichever is easier to read for the scenarios you're writing.
runScenarios no longer exits the process. It sets process.exitCode on failure and returns a boolean, so you can split scenarios across several __tests__/*.test.js files (by module or feature) and run them all in one node process with runScenarioFiles():
// __tests__/index.test.js
const { runScenarioFiles } = require("@owncast/plugin-sdk/testing");
runScenarioFiles(); // discovers and runs the sibling *.test.js files, aggregating pass/failPoint your test script at that one entry (node __tests__/index.test.js) instead of looping over files in the shell.
event: "<type>", fire-and-forget notification dispatchfilter: "<type>", filter chain. Inlineexpect: {action, payload?, reason?}checks the FilterResulthttp: { method, path, headers?, body?, user?, authenticated?, expect: {status, headers?, body?, bodyContains?} }, sends an HTTP request through your plugin servertabContent: { slug, user?, expect: {body?, bodyContains?} }, callsonTabContentdirectly and asserts on the returned HTMLpageContent: { slug, user?, expect: {body?, bodyContains?} }, callsonPageContentdirectly and asserts on the returned HTMLpageStyles: { expect: {body?, bodyContains?} }, callsonPageStylesdirectly and asserts on the returned CSSpageScripts: { expect: {body?, bodyContains?} }, callsonPageScriptsdirectly and asserts on the returned JavaScriptauthCheck: { user: {...}, expect: {action: "ok" | "refresh" | "deny", reason?} }, callsonAuthCheckwith a resolved viewer identity and asserts the verdict (forauth.gateplugins)
[
{
"name": "stream-info tab renders live title",
"given": { "stream": { "online": true, "title": "Friday Night", "viewers": 12 } },
"events": [
{
"tabContent": {
"slug": "stream-info",
"expect": { "bodyContains": "Friday Night" }
}
}
]
},
{
"name": "banner greets authenticated viewer by name",
"events": [
{
"pageContent": {
"slug": "banner",
"user": { "id": "u1", "displayName": "Alice" },
"expect": { "bodyContains": "Alice" }
}
}
]
}
]Per-step expect (on filter and http steps):
- For filters:
action: "pass" | "modify" | "drop",payload,reason - For HTTP:
status,headers,body
Final-state expect (on the whole scenario):
chatSends,chatActions,chatSystems, exact-match lists of chat posts (the bot-sent, "/me" action, and system message variants). Captures sends from any step, including chat posted from anonHttpRequesthandler.chatTo, list of{clientId, text}private replies (owncast.chat.sendTo/replyTo)sseSends, ordered list of{channel, event?, data?}pushed viaowncast.sse.send(omitevent/datato match only on channel)videoConfigWrites, list of partial configs applied viaowncast.videoConfig.write()emits, list of{eventType, payload}for custom eventskv, partial map of your plugin's key/value store after the scenariohttpRequests, outbound HTTP made by your plugin
given.kv, pre-populate your plugin's key/value store (whatowncast.kv.getreads)given.config, admin-set overrides for manifest-declared config keys, e.g."given": { "config": { "cooldownMs": 500 } }.owncast.config.getreturns the override instead of the declared default. Only keys declared underconfigin the manifest are visible to the plugin.given.stream, whatowncast.stream.current()returnsgiven.broadcaster, whatowncast.stream.broadcaster()returnsgiven.server, whatowncast.server.info()returnsgiven.socials/given.federation/given.tags, what the matchingowncast.server.*reads returngiven.videoConfig, whatowncast.videoConfig.read()returnsgiven.chatHistory, whatowncast.chat.history()returnsgiven.chatClients, whatowncast.chat.clients()returnsgiven.users, whatowncast.users.list()/.get(id)returnsgiven.httpResponses, canned responses for outboundowncast.http.fetchcalls. Each entry'surlis a glob matched against the full URL ("https://api.example.com/user*"covers any query string) and the first matching entry wins. One canned response per pattern: a sequence where the same URL must answer differently across calls (e.g. 401, then 200 after a token refresh) can't be modeled, so unit-test that branch outside the runner.
Without given.config, config reads return the manifest-declared defaults (exactly like a production host where the admin hasn't edited the settings). Test both paths: an override scenario and a defaults/unconfigured scenario.
For testing admin endpoints, add authenticated: true to an HTTP step:
{
http: {
method: "GET",
path: "/admin/api/settings",
authenticated: true,
expect: { status: 200 },
},
}For testing user-token endpoints, set user instead, the user identity is forwarded to the plugin as req.user:
{
http: {
method: "GET",
path: "/my-data",
user: { id: "u1", displayName: "alice", scopes: ["MODERATOR"] },
},
}Without either flag, requests are treated as unauthenticated. Requests to manifest-declared admin paths return 401.
npm run serveLoads your plugin and serves it on http://localhost:8080/plugins/<your-name>/. Useful for browser-testing static pages and curl-ing HTTP endpoints. Override the port with PORT=8765 npm run serve.
It also exposes dev-only endpoints to drive your event and filter handlers (which a plain HTTP server can't reach), and host reads (server info, video config, etc.) return sample dev data:
POST /_dev/chatwith{"user":"alice","body":"hi"}, runs yourfilterChatMessagechain, then fireschat.message.received(onChatMessage). The JSON response shows what your filter did.GET /_dev/chat, the chat log so far, including anything your plugin posted.POST /_dev/eventwith{"type":"stream.started","payload":{}}, dispatch an arbitrary event to youronEventhandlers.
For repeatable assertions use npm test (scenario tests). The dev server is for interactive iteration. Many authors run both: dev server in one terminal, test watcher in another.
A .ocpkg is the distribution format: a single file containing your plugin.manifest.json, your plugin source (plugin.js for JavaScript or plugin.py for Python, since the interpreter lives in the Owncast host, so you ship source, not a compiled module), your public/ and assets/ directories if you have them, and the optional icon.png and INSTRUCTIONS.md. The host infers the runtime from that filename, so the manifest needs no type field. It's what a server admin installs into Owncast, and they don't see your package.json, node_modules, or anything else.
Bundle the .ocpkg:
npm run packageThe file is self-contained. Share it however you like (release on GitHub, hand it to an admin over chat, host it somewhere). The admin has two ways to install it:
- Upload from the admin UI. Open Plugins in the Owncast admin and click Upload plugin. The new entry appears in the list immediately.
- Drop it into
data/plugins/on the server. The directory is scanned periodically. The plugin appears in the admin within a couple of seconds.
Either way, the admin then reviews the Permissions tab on the plugin's detail page and toggles Enabled to load it. Subsequent updates (uploading a new .ocpkg with the same name) replace the existing entry and trigger the re-approval flow if the new manifest declares additional permissions.
To remove a plugin, an admin clicks the trash icon on the plugin's row in the Plugins page and confirms.
npm run build produces only the bundled <slug>.js and is faster, useful while iterating. npm run package is the step you run when you're ready to ship.
To make your plugin discoverable to other operators rather than handing the .ocpkg around yourself, publish it to the plugin directory at owncast.directory. See the Publishing your plugin guide for the submission process.
Complete working plugins, also in examples/js/:
Replies to every chat message.
{
"api": "1",
"name": "Echo Bot",
"slug": "echo-bot",
"version": "0.1.0",
"permissions": ["chat.send"],
"bot": { "displayName": "Echo" }
}const { definePlugin, owncast } = require("@owncast/plugin-sdk");
module.exports = definePlugin({
onChatMessage(msg) {
owncast.chat.send(`${msg.user?.displayName ?? "someone"} said: ${msg.body}`);
},
});const { definePlugin, filter } = require("@owncast/plugin-sdk");
const WORDLIST = ["damn", "hell", "crap"];
module.exports = definePlugin({
filterChatMessage(msg) {
let body = msg.body;
let modified = false;
for (const word of WORDLIST) {
const re = new RegExp("\\b" + word + "\\b", "gi");
if (re.test(body)) {
body = body.replace(re, "*".repeat(word.length));
modified = true;
}
}
return modified ? filter.modify({ ...msg, body }) : filter.pass();
},
});Drops rapid follow-ups. Uses plugin config for per-user state.
const { definePlugin, filter, owncast } = require("@owncast/plugin-sdk");
const MIN_INTERVAL_MS = 2000;
module.exports = definePlugin({
filterChatMessage(msg) {
const now = new Date(msg.timestamp).getTime();
// Key off the stable user id, not the display name (which can change).
const who = msg.user?.id ?? "anon";
const last = parseInt(owncast.kv.get(`last:${who}`) || "0", 10);
if (last && now - last < MIN_INTERVAL_MS) {
return filter.drop(`${who} must wait ${MIN_INTERVAL_MS}ms`);
}
owncast.kv.set(`last:${who}`, String(now));
return filter.pass();
},
});Responds to !ip by calling an external API.
const { definePlugin, owncast } = require("@owncast/plugin-sdk");
module.exports = definePlugin({
onChatMessage(msg) {
if (msg.body.trim() !== "!ip") return;
const res = owncast.http.fetch("https://api.ipify.org?format=json");
if (res.status !== 200) return;
const { ip } = JSON.parse(res.body);
owncast.chat.send(`server IP: ${ip}`);
},
});Ships an HTML page that polls a JSON endpoint backed by owncast.chat.history().
{
"api": "1",
"name": "Chat Overlay",
"slug": "overlay",
"version": "0.1.0",
"permissions": ["http.serve", "chat.history"]
}const { definePlugin, owncast } = require("@owncast/plugin-sdk");
module.exports = definePlugin({
onHttpRequest(req) {
if (req.method === "GET" && req.path === "/api/messages") {
const messages = owncast.chat.history(20);
return {
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages }),
};
}
return { status: 404 };
},
});<!-- public/index.html -->
<!doctype html>
<body>
<div id="messages"></div>
<script>
setInterval(async () => {
const { messages } = await (await fetch("./api/messages")).json();
document.getElementById("messages").innerHTML = messages
.map((m) => `<div>${m.user}: ${m.body}</div>`)
.join("");
}, 2000);
</script>
</body>Visit /plugins/overlay/ to render. Owncast owns the chat history. Your plugin just exposes a view of it.
{
"api": "1",
"name": "Stream Tracker",
"slug": "stream-tracker",
"version": "0.1.0",
"permissions": ["chat.send", "server.read"]
}const { definePlugin, owncast } = require("@owncast/plugin-sdk");
module.exports = definePlugin({
onStreamStarted(info) {
owncast.chat.sendAction(`is live: ${info.title || "stream"}`);
},
onChatMessage(msg) {
if (msg.body.trim() !== "!uptime") return;
const state = owncast.stream.current();
if (!state.online) {
owncast.chat.send("stream is offline");
return;
}
const seconds = Math.floor(
(new Date(msg.timestamp).getTime() -
new Date(state.startedAt).getTime()) /
1000,
);
owncast.chat.send(`uptime: ${seconds}s, ${state.viewers} viewer(s)`);
},
});Messages from this plugin appear in chat from the stream-tracker bot account, the auto-bot Owncast provisions on install.
relay watches for /announce in chat and emits a custom event. announcer subscribes.
// relay/src/plugin.js, needs events.emit
module.exports = definePlugin({
onChatMessage(msg) {
if (!msg.body.startsWith("/announce ")) return;
owncast.events.emit("announcement.broadcast", {
text: msg.body.substring(10),
by: msg.user?.displayName,
});
},
});// announcer/src/plugin.js, no permissions needed
module.exports = definePlugin({
on: {
"announcement.broadcast"(payload) {
console.log(`📢 ${payload.by}: ${payload.text}`);
},
},
});- TypeScript works, name your file
src/plugin.tsinstead of.js. The SDK ships TypeScript declarations. Useimportinstead ofrequire. - Third-party code is limited. npm packages must be pure JavaScript (no Node built-ins like
fsorhttp). Python has nopip: to use a library, copy its pure-Python source into your project. For outbound HTTP callowncast.http.fetch, notrequestsor Node'shttp. console.login plugin code surfaces in the host log with a[your-plugin]prefix. Use it freely for debugging.- One handler = one subscription. Define
onChatMessage→ subscribed. Delete it → unsubscribed. Don't think about it. - Mocked tests are fast (3-5 s including rebuild). Run them on every save.
- State doesn't leak between scenarios. Each test gets a fresh plugin instance and a clean in-memory plugin config.
The runtime watches for filters that consistently throw or hang. Two protections:
Timeouts. Each filter call is capped at 50 ms. Past that, the host cancels the call and treats it as a failure (fail-open: the filter is skipped, the chain continues with the unmodified payload). This protects the chat hot path from a single slow filter.
Strike system. After 5 consecutive failures (errors or timeouts) the host auto-disables the plugin for the rest of the session and logs once:
plugin <slug>: auto-disabled after 5 consecutive filter failures
Disabled plugins are silently skipped by the filter chain. A successful filter call resets the counter, so transient flakiness doesn't accumulate. To re-enable a disabled plugin, restart the host.
Note: the 50 ms cancellation is enforced by the wasm engine itself, which checks the deadline at loop boundaries, so even a tight pure-JS loop with no calls out (e.g.
while (true) {}) is interrupted at about 50 ms rather than running to the 10 s ceiling. Repeated filter timeouts trip the strike system, which disables the plugin. Realistic plugin code finishes well under the limit, so this isn't something to design around.