The contract between the Owncast host runtime and a plugin. This document is the source of truth that every language SDK (and the host implementation in the Owncast server repo) implements.
At the wasm ABI a plugin is a module exposing a fixed set of well-known exports and importing a fixed set of host functions. For interpreted plugins (JavaScript, Python) that module is the host-embedded shared engine (one per language, compiled once and instantiated per plugin) running the plugin's source, which the host injects via Extism config at load. A plugin authored directly as a self-contained wasm module presents the same ABI itself. Either way the protocol below is identical. Communication is single-buffer in / single-buffer out: the host writes a JSON or text body before the call, the plugin reads it via the Extism Host.input*() helpers, and any return value is written via Host.output*().
Every plugin must export these functions:
| Function | Input | Output | Purpose |
|---|---|---|---|
register |
none | JSON Manifest |
Returns derived subscriptions and command declarations. |
on_event |
JSON Envelope |
none | Notification dispatch. Fire-and-forget. |
on_filter |
JSON Envelope |
JSON FilterResult |
Filter chain entry point. |
on_http_request |
JSON IncomingHttpRequest |
JSON OutgoingHttpResponse |
HTTP request handler for /plugins/<name>/*. |
on_tab_content |
JSON ContentRequest |
raw HTML string | Render HTML for a dynamic tab (one without a static content file in the manifest). |
on_page_content |
JSON ContentRequest |
raw HTML string | Render HTML for a dynamic extraPageContent slot (one without a static content file). |
on_page_styles |
none | raw CSS string | Optional. Return CSS to append to customStyles on /api/config. Called only when the plugin holds ui.modify. |
on_page_scripts |
none | raw JavaScript string | Optional. Return JavaScript to append to /customjavascript. Same gating as on_page_styles. |
on_auth_check |
JSON AuthCheckRequest |
JSON AuthCheckResult |
Optional. Re-validate a gate session on the viewer's / page load. Returns {action: ok|refresh|deny}. Called by the host only for the active auth.gate plugin. |
ContentRequest shape: { "slug": "<tab-or-slot-slug>", "user"?: User }. The host calls the appropriate export when building the /api/config response. The returned string is inlined directly as the body. An empty string is valid (renders nothing). Each entry point has a per-call timeout enforced by the host. See the host's dispatcher.go and server.go for current values.
on_page_styles and on_page_scripts take no input and return no per-viewer content, so the /api/config response stays cacheable. They are the dynamic counterparts to the static manifest.styles and manifest.scripts files: the host appends their output after the static files in the same customStyles / /customjavascript slots. The host calls them once per /api/config for any plugin that exports them and holds ui.modify, and wraps each script contribution (static and dynamic) in a try/catch so one plugin's runtime error can't break the shared bundle. A plugin opts in purely by exporting the function, with no manifest field.
register returns the static manifest echoed back, plus two fields the SDK
derives at runtime:
subscriptions:{ notify: [{event}], filter: [{event, priority?}] }, derived from the plugin's ordinary handlers. The host validates these against the sidecar manifest's permissions.commands:[{ name, prefix, description?, usage?, aliases?, modOnly?, caseSensitive?, cooldownMs? }]. The host matches accepted human chat messages against every loaded plugin's declarations. Duplicate commands all run. Moderator failures, cooldown rejections, and unknown commands are silent. The same metadata builds the built-in!helpresponse.
Matched declarations receive an internal chat.command envelope through
on_event. Its payload is
{ message, command, invokedAs, args, argString }. message is the original
ChatMessage, command is the canonical declaration, and invokedAs preserves
the name or alias the sender typed. The SDK maps this event to the declared
handler. Plugins do not subscribe to chat.command, cannot emit it, and need no
permission to receive it. Permissions still apply to actions taken by the
handler.
Command matching runs after chat filters and ordinary chat notification.
Filtered messages cannot execute commands. Command messages, including
!help, remain ordinary chat messages. Owncast's built-in help response does
not prevent plugins from also responding.
Because all plugins of a language share one engine, the engine imports the full set of host functions and the host enforces permissions at call time: every host function resolves the calling plugin's identity (from a per-instance config value the host sets at load) and rejects the call (returning a zero/empty result and logging) when the plugin's manifest didn't grant the matching permission. The SDK wrappers still map one-to-one to these imports, so an author only ever calls the ones their permissions allow. (A self-contained wasm plugin that imports an ungranted host function instead fails to link at instantiation, the older structural enforcement.)
owncast_send_chat(textPtr: PTR): void, plugin's bot identity, regular messageowncast_send_chat_action(textPtr: PTR): void, same identity, "/me" action styleowncast_send_chat_system(bodyPtr: PTR): void, no user identity, body rendered as HTMLowncast_send_chat_to(clientId: I64, textPtr: PTR): void, private DM to one client
owncast_chat_history(limit: I32): PTR, returns JSONChatMessage[]owncast_chat_clients(): PTR, returns JSONChatClient[]
owncast_delete_message(idPtr: PTR): voidowncast_kick_client(clientId: I64): void
owncast_kv_get(keyPtr: PTR): PTR, returns text or 0-offset on missowncast_kv_set(keyPtr: PTR, valPtr: PTR): void
owncast_storage_upload(namePtr: PTR, dataPtr: PTR): PTR, returns JSON{url}or 0-offset on failure
Sandboxed per-plugin filesystem under data/plugin-data/<slug>/. The host confines every path to the plugin's own directory.
owncast_fs_read(pathPtr: PTR): PTR, returns the file's raw bytes, or 0-offset when missing/unreadableowncast_fs_write(pathPtr: PTR, dataPtr: PTR): PTR, returns JSON{ok, error?}owncast_fs_list(dirPtr: PTR): PTR, returns JSONstring[]of entry names (missing dir → empty)owncast_fs_delete(pathPtr: PTR): PTR, returns JSON{ok, error?}owncast_fs_exists(pathPtr: PTR): I32, returns 1 if the path exists, 0 otherwise
owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void, payload is a JSON-encoded value
owncast_stream_current(): PTR, JSONStreamInfoowncast_stream_broadcaster(): PTR, JSONStreamBroadcaster(read-only inbound-feed telemetry)owncast_server_info(): PTR, JSONServerInfoowncast_server_socials(): PTR, JSONSocialHandle[]owncast_server_emotes(): PTR, JSONEmote[](custom chat emotes,{name, url})owncast_server_federation(): PTR, JSONFederationInfoowncast_server_tags(): PTR, JSONstring[]
owncast_video_config_read(): PTR, JSONVideoConfig({latencyLevel, codec, variants})
owncast_video_config_write(configPtr: PTR): PTR, applies a partialVideoConfigUpdate. Returns JSON{ok, error?}
owncast_notify_discord(textPtr: PTR): voidowncast_notify_browser_push(payloadPtr: PTR): void, JSONBrowserPushPayloadowncast_notify_fediverse(payloadPtr: PTR): void, JSONFediversePayload
owncast_users_list(): PTR, JSONUser[]owncast_user_get(idPtr: PTR): PTR, JSONUseror 0-offset on miss
owncast_user_set_enabled(idPtr: PTR, enabled: I32, reasonPtr: PTR): voidowncast_ban_ip(ipPtr: PTR): void
Find-or-create an authenticated Owncast user for an external identity. Used by
a viewer-auth gate (see auth.gate) to turn a provider login into
a real Owncast user before granting it a session.
owncast_users_register(reqPtr: PTR): PTR, JSONUserRegisterRequestin, JSONUserRegisterResultout. The host namespaces the request'sauthIdby the calling plugin's slug, so two plugins can't collide on or impersonate each other's users. The host restricts whichscopesa plugin may assign and rejects administrative scopes, so an out-of-policy scope fails the call.
Grants a plugin the right to be the site's viewer-authentication gate: it
renders the login flow and names the authenticated user, and the host owns the
signed session cookie end to end (the plugin never sees the token). Only one
auth.gate plugin can be enabled at a time. Both functions are meaningful only
inside an on_http_request handler, where the host attaches or clears the
session cookie on the response after the call returns.
owncast_auth_grant_session(reqPtr: PTR): PTR, JSONGrantSessionRequestin, JSON{error?}out. Mints a session for the nameduserId(which the same plugin must have registered viausers.register) and attaches the signed cookie to the in-flight response.owncast_auth_end_session(): void, clears the session cookie on the in-flight response (logout).
The optional on_auth_check export (see Exports) lets
the gate re-validate a session on each / page load and return
ok / refresh / deny.
owncast_fediverse_post(textPtr: PTR): PTR, JSON{url}or 0-offset on failure
- Not a custom host function, grants the plugin access to Extism's built-in
Http.request. The host configures Extism'sAllowedHostsfrom the manifest'snetwork.allowedHosts(see Manifest extensions below). Manifests grantingnetwork.fetchwithoutnetwork.allowedHostsare rejected at load.
- Not a host function. Grants the host's HTTP server permission to route
/plugins/<name>/*requests to this plugin'son_http_requestexport and to serve static files from itspublic/directory. The plugin's separateassets/directory is read by the host for manifest fields that inline content (styles,scripts,extraPageContent) and is never reachable through the plugin's URL space.
owncast_sse_send(channelPtr: PTR, eventPtr: PTR, dataPtr: PTR): void, push one Server-Sent-Events message to every browser connected to(this plugin, channel).channelandeventare plain strings.datais the message body (the SDK JSON-encodes non-string values). Fire-and-forget: the call returns immediately and never blocks on a slow or absent client.- Grants the host permission to serve the reserved
/plugins/<name>/_sse/<channel>endpoint (see Host-reserved endpoints). Independent ofhttp.serve: a plugin may stream events without serving any other routes.
- Not a custom host function. Gates UI surfaces that place plugin-contributed elements inside Owncast's own chrome.
- Required when the manifest declares
actions[],styles[],scripts[],extraPageContent, ortabs[], and required at runtime byowncast_add_actions/owncast_clear_actions. Manifests that declare any of those fields withoutui.modifyare rejected at load. Runtime calls return a permission error. owncast_add_actions(jsonPtr: PTR): u64, append one or moreActionButtonentries on top ofmanifest.actions. Argument is a JSON array. The host validates each entry with the same rules as the manifest (title required, exactly one ofurl/html, relative URLs and icons auto-prefixed to the plugin's namespace, cross-plugin paths rejected) and persists the merged set to the plugin's config. Returns the host call envelope (success indicator + optional error string).owncast_clear_actions(jsonPtr: PTR): u64, drop every runtime addition.manifest.actionsare untouched. Argument is an empty JSON object ("{}") for API symmetry. Returns the host call envelope.
- Not a custom host function. Gates the
filter_chat_messageexport: a plugin that registers afilterChatMessagehandler must declare this permission at load time, otherwise the host rejects the manifest. - This is deliberately separate from
chat.send,chat.history, andchat.moderate: filtering happens inline on every chat message before broadcast (modify the body, drop the message, or pass it through), so the manifest reviewer needs to see it called out explicitly.
- Not a custom host function. Gates notify subscriptions to
fediverse.activity,fediverse.follow,fediverse.like,fediverse.repost,fediverse.quote,fediverse.mention, andfediverse.reply. Ifregisterreports any of these subscriptions, the plugin manifest must declare this permission or the host rejects the plugin at load time. fediverse.activitycarries the verified inbound activity's raw JSON object after HTTP signature and actor-origin checks. It is dispatched in addition to any matching specialized event and is also dispatched for verified activity types with no specialized event.fediverse.quotecarries{actor, target}, wheretargetis the locally authored post that the remote actor quoted.fediverse.mentionandfediverse.replyare limited to verified publicCreate(Note)activities that mention the local account or reply to a locally authored post.- These are internal plugin notify events, not external HTTP webhooks.
fediverse.postis separate and permits outbound posts under the streamer's Fediverse identity.
These imports are granted to every plugin without a declared permission. A plugin can't setTimeout or read its own config without the host, and the acts themselves are benign (a scheduled callback still needs its own permissions to do anything, and reading your own manifest-declared config exposes nothing new).
owncast_timer_set(id: I64, delayMs: I64, repeat: I32): I32, schedule a host-driven timer. The host fires thetimer.fireevent (payload{id}) when it elapses. Returns 1 on success, 0 if the plugin is at its pending-timer cap.delayMsis clamped to[100, 86_400_000]. The SDK mapsid→callback forowncast.timer.setTimeout/setInterval.owncast_timer_clear(id: I64): void, cancel a pending timer by id.owncast_config_get(keyPtr: PTR): PTR, returns the JSON value of amanifest.configkey (admin override, else declared default), or 0-offset for an unknown/unset key.owncast_asset_read(pathPtr: PTR): PTR, returns the raw bytes of a file from the plugin's ownassets/directory, or 0-offset when the file is missing or the path escapes the directory. The path is relative toassets/and must not start with/or contain..segments. The host rejects any path that would escape the plugin's own asset tree. Plugins use this to load bundled resources (templates, data files) at request time without needingstorage.fs.
The host also dispatches a tick event (payload {now}, host wall-clock ms) about once a second to any plugin defining onTick, independent of timers.
These paths under /plugins/<name>/ are owned by the host. The plugin's on_http_request never sees them. They cannot be overridden by a plugin's own routes.
Returns the raw bytes of the plugin's icon.png if one was bundled at the root of the .ocpkg (or next to the plugin's code file as <base>.icon.png for the loose-files layout). 404 when no icon is present. No http.serve permission required: this is a host endpoint, served independently of the plugin's own routes, so a plugin that ships an icon for the admin UI doesn't need any HTTP surface of its own. Returned with Content-Type: image/png and Cache-Control: no-cache so a swapped icon shows up on the next admin reload.
Returns the raw markdown of the plugin's INSTRUCTIONS.md if one was bundled at the root of the .ocpkg (or next to the plugin's code file as <base>.INSTRUCTIONS.md for the loose-files layout). 404 when none is present. Admin-authenticated, since it's part of the plugin-management API rather than a public asset. No http.serve permission required. Returned with Content-Type: text/markdown and Cache-Control: no-cache so swapped instructions show up on the next admin reload. The admin UI renders the markdown in an Instructions tab on the plugin's details page.
A long-lived Server-Sent-Events stream. The browser opens it with EventSource. The host holds the connection open and writes each frame the plugin pushes via owncast.sse.send(channel, …). The segment after _sse/ is the channel name (empty selects the default channel), letting one plugin run several independent streams (e.g. overlay and admin-stats).
The plugin process is not involved in serving the connection, no wasm call is made per request and the per-plugin call mutex is never held, so an idle stream costs only a goroutine. This is the supported way to do realtime push: a plugin's own on_http_request cannot stream, because each call is a single buffered request/response bounded by the HTTP handler timeout.
Host behavior:
- Requires the
http.ssepermission, 404 otherwise. - A channel that matches a
manifest.admin.pages[]glob is auth-gated like any other admin path (401 if not authenticated). - Connections are capped per-plugin (default 64). Over the cap returns 503.
- Idle streams get a
: keep-alivecomment line every 15s so proxies don't drop them. - Delivery is best-effort: each client has a small send buffer, and frames are dropped for a client that can't keep up rather than blocking the publishing plugin.
- Frame format: an
event: <name>line when the event is non-empty, onedata: <line>per newline in the body, terminated by a blank line.
The plugin manifest carries a few host-facing declarations beyond identity and permissions. The host parses these at load time. They don't ride over wasm.
An array of ActionButton entries the host merges into Owncast's external-action list while the plugin is enabled. Shape matches Owncast's ExternalAction:
{
"title": "string (required)",
"url": "string (URL or relative path; mutually exclusive with html)",
"html": "string (raw HTML; mutually exclusive with url)",
"icon": "string (URL or relative path)",
"color": "string (hex)",
"description": "string",
"openExternally": false
}Host validation:
titlerequired. Exactly one ofurlorhtmlrequired.ui.modifypermission required (seeui.modify).- Relative
urlpaths starting with/but not/plugins/are rewritten to/plugins/<plugin-name>/<path>. - URLs resolving into the plugin's own namespace require
http.serve, load fails otherwise. - URLs pointing at another plugin's namespace are rejected at load.
- The
iconfield follows the same path-handling rules asurl: relative paths auto-prefix into the plugin's namespace (and requirehttp.serveto actually serve), absolutehttps://...URLs pass through, cross-plugin icon paths are rejected.
Runtime additions go through owncast_add_actions / owncast_clear_actions (see ui.modify). The host validates each runtime entry with the same rules above and persists the merged set under the reserved owncast.actions key inside the plugin's config.
The host exposes the merged list as GET /api/plugins/actions (public). The Owncast server is responsible for folding that into its existing /api/externalactions response.
Glob-matched routes inside /plugins/<name>/... that the host auth-gates before reaching the plugin's on_http_request. See manifest.go:AdminPage.
Hostname globs the plugin is allowed to reach via owncast.http.fetch. Passed straight through to Extism's AllowedHosts. Required when network.fetch is granted. The wildcard "*" is permitted but must be written explicitly so the manifest reflects the granted scope.
The host surfaces this list on GET /api/admin/plugins (as allowedHosts: []string on each DiscoveredEntry) and the admin UI renders it alongside the network.fetch row in the Permissions tab, so an admin reviewing a plugin sees exactly which hosts it can reach without unpacking the .ocpkg.
An array of relative paths to CSS files the plugin contributes to the viewer page. The host reads each file's bytes from the plugin's assets/ directory and appends them to the admin's customStyles in the /api/config response, so a viewer renders one <style> block covering admin and plugin contributions. The file is never reachable through the plugin's URL space.
Per-entry validation:
ui.modifypermission required (the file is inlined, not served, sohttp.serveis not required).- Bare or single-slash paths (
"theme.css","/theme.css") auto-prefix to/plugins/<name>/theme.css. - Fully qualified
/plugins/<name>/...paths pass through. - Paths in another plugin's namespace are rejected at load.
http://andhttps://URLs are rejected at load.- Each entry must end in
.css.
Each plugin contribution in the concatenated response is preceded by a /* plugin: <slug> — <file> */\n comment so devtools "view source" can attribute a rule back to whichever plugin shipped it. Disabling the plugin drops its contribution on the next /api/config request.
An array of relative paths to JavaScript files the plugin contributes to the viewer page. The host reads each file's bytes from the plugin's assets/ directory and appends them to the response served at /customjavascript, so a viewer loads one <script> tag covering admin and plugin contributions.
Same per-entry rules as manifest.styles[], applied to .js files (only ui.modify required, and the file is inlined, not served). Contributions are separated by // plugin: <slug> — <file>\n delimiter comments. Every plugin's JavaScript runs in the viewer page's shared global scope, so authors are expected to wrap their script in an IIFE so top-level declarations don't collide.
An object the plugin contributes to the viewer's extra-content block. The host prepends the resolved HTML to the admin's rendered extraPageContent on /api/config, so plugin HTML lands above the admin's prose.
{
"slug": "string (required, identifies the slot, passed to on_page_content)",
"content": "string (optional, relative path to assets/<file>.html)"
}Validation:
ui.modifypermission required.http.serveis not required: the HTML is inlined into the API response, not served at a URL.slugmust be a valid slug (lowercase letters/digits/hyphens, starting with a letter, max 64 chars).- When
contentis present, the same path-shape rules asmanifest.styles[]apply (must end in.html).
Static (content present): the host reads the file from assets/ and inlines its bytes.
Dynamic (content absent): the host calls on_page_content with { slug, user? } and inlines the returned HTML string. user carries the requesting viewer's chat identity when available.
Each contribution is wrapped with an <!-- plugin: <slug> — <file> -->\n comment for in-page attribution. The admin's content goes through the markdown processor before plugin HTML is prepended. Plugin HTML is left raw so tags and attributes pass through as written.
An array of viewer-page tabs the plugin contributes alongside the built-in tabs (Followers, About).
{
"title": "string (required, tab label)",
"slug": "string (required, stable identifier, passed to on_tab_content)",
"content": "string (optional, relative path to assets/<file>.html)"
}Validation:
ui.modifypermission required.http.serveis not required: each tab's HTML is inlined into the response, not served at a URL.titlemust be non-empty. Unique within the plugin's tabs.slugmust be a valid slug. Unique within the plugin's tabs. Passed toon_tab_contentso the plugin knows which tab to render.- When
contentis present, the same path rules asmanifest.extraPageContent.contentapply (must end in.html).
Static (content present): the host reads the file from assets/ and inlines its bytes.
Dynamic (content absent): the host calls on_tab_content with { slug, user? } and inlines the returned HTML string.
The host emits the tab list on GET /api/config under pluginTabs[] as [{slug, title, html}] entries. The viewer page maps each entry to a tab whose body renders the inlined HTML. slug doubles as the React key so a tab only unmounts when the source plugin is disabled/removed.
The JSON shapes for Manifest, Envelope, ChatMessage, FediverseInboundPost, UserRegisterRequest, GrantSessionRequest, AuthCheckResult, etc. are documented in the JS SDK's index.d.ts as TypeScript interfaces. Future SDKs (Go, Python) port these shapes into their native type system. The over-the-wire JSON is identical.
Each language SDK is responsible for:
- Declaring the imports listed above (gated by manifest permissions) so the plugin author's call into
owncast.chat.send(...)resolves to the right wasm import. - Encoding/decoding payloads as JSON or text per the table above.
- Implementing the exports' dispatch loop: parse the envelope, route to the right handler, serialize the response. This covers all nine exports:
register,on_event,on_filter,on_http_request,on_tab_content,on_page_content,on_page_styles,on_page_scripts, andon_auth_check.
The Owncast server repo's plugin runtime is responsible for:
- Registering each host function under the right Extism namespace and permission gate.
- Calling exports with the right input shapes and observing the documented timeout / size caps.
The Owncast repo's services/plugins/contract_test.go re-derives the permission and host-function names (and wire-type field shapes) from the host implementation and compares them against the committed plugin-contract.json snapshot, so the runtime can't silently drift from the contract this document describes.