A system-level tour of what's in this repository and how the pieces fit together. This is informational, for writing a plugin, see the Plugin Author Guide. For the byte-level host/plugin protocol, see the Wire Protocol.
This repository is where the Owncast plugin system is developed. It contains:
- the JavaScript and Python SDKs authors write plugins against, plus the build CLIs and a project scaffolder,
- the shared interpreter engines (one per language) and the toolchain that builds them,
- the host runtime (Go) that loads and runs plugins. The runtime itself now
lives in Owncast (
services/plugins) and is imported here (see Relationship to Owncast), - example plugins (parallel JS and Python ports) and their tests.
Plugins are authored in JavaScript/TypeScript or Python. Rather than each
plugin compiling its own interpreter into a self-contained module, the host
embeds one shared engine per language, a QuickJS (JS) or CPython (Python)
interpreter compiled to WebAssembly with the Extism PDK
(extism-js /
extism-py). The host compiles each
engine once with Extism on Wazero
(pure-Go wasm, no CGo/subprocess) and instantiates it per plugin, injecting
the plugin's source via Extism config at load. This collapses per-plugin memory
(N plugins share one compiled engine instead of N copies) and shrinks plugin
packages from megabytes to a few KB.
A plugin authored directly as a self-contained wasm module (Rust/Go/etc.) is also supported and loaded as-is. The host picks the path from the package's code file (see build flow).
- Every plugin exports the same fixed functions:
register,on_event,on_filter,on_http_request,on_tab_content,on_page_content,on_page_styles,on_page_scripts, andon_auth_check. Most are optional. See the Wire Protocol for the full table and which permissions gate them. - The host provides host functions (
owncast_*). Because all plugins of a language share one engine, the engine imports the full set and the host enforces each plugin's permissions at call time: a host function resolves the calling plugin's identity (from a per-instance config value) and rejects the call if the plugin's manifest didn't grant the permission. - Everything crosses the boundary as JSON.
- Inbound Fediverse hooks are internal notify subscriptions. They are not
external HTTP webhooks. Owncast verifies the HTTP signature and actor origin,
then sends the raw activity to
onFediverse/on_fediverseand also sends any matching specialized follow, like, repost, quote, mention, or reply event. Thefediverse.inboundmanifest permission gates all seven subscriptions.
host-runtime/ Go module: imports the runtime + builds the two Go CLIs
cmd/owncast-plugin-serve/ localhost dev server
cmd/owncast-plugin-test/ scenario test runner
main.go a demo host that simulates a stream
sdks/js/ @owncast/plugin-sdk, the npm package
index.js definePlugin(), command handlers + owncast.* wrappers
index.d.ts TypeScript types (the author-facing contract)
bin/owncast-plugin.js the build/package/test/serve CLI
scripts/postinstall.js fetches the test/serve binaries
create-owncast-plugin/ npm initializer (scaffolder)
sdks/python/ owncast-plugin-py: the Python SDK + build CLI
engines/ the shared engines' fixed bootstrap + build scripts
javascript/entry.js the JS engine bootstrap (SDK + dispatch + script loader)
build.mjs, build_py.py build the engine wasms, copy them into Owncast's embed dir
examples/js/, examples/python/ parallel example plugins (one dir per plugin)
tools/ engine-build toolchain (extism-js/py, binaryen; gitignored)
docs/ these documents
.github/workflows/ release workflow for the Go binaries
The core library. It lives in the Owncast repo (services/plugins) as the
single source of truth, and host-runtime/ here imports it so the dev CLIs run
the exact production code. Key files:
manager.go, discovers plugins in a directory, tracks them as discovered vs enabled, and handles enable/disable/reload. The enabled set persists through a pluggableEnabledStore(a JSON file by default. Owncast swaps in a datastore-backed store).dispatcher.go, fans ordinary events out to subscribed plugins, delivers targeted internal events, and runson_filterchains.server.go+sse.go, serve/plugins/<name>/*(static assets + the plugin'son_http_request) and a host-owned Server-Sent-Events endpoint the plugin pushes to.hostfns.go, the heart of the contract: the host-function definitions, the permission constants, and the types plugins receive. Every host function reads a function-pointer field from aHostEnvstruct and resolves the calling plugin's identity + permission at call time (seeregistry.go).engines/+engine_cache.go, the embedded per-language engine wasms (go:embed) and the cache that compiles each once and instantiates it per plugin.registry.go, the per-plugin identity registry shared host functions look up to scope a call (slug, granted permissions, kv namespace, assets). It is the call-time replacement for the old per-plugin closures.commands.go, matches accepted chat messages against every plugin's command declarations, applies moderator gates and cooldowns, and dispatches the internalchat.commandevent to every match.help.go, the host-owned unified!help: aggregates each plugin's reported command metadata and renders the listing.kv/, the key/value store interface plugins get (memory + bolt implementations here. Owncast backs it with its datastore).testing/, a mock host (MockHost) and the scenario runner used byowncast-plugin-test.
hostfns.go is intentionally host-agnostic. A host function like
owncast_video_config_read just calls env.VideoConfig(), a field on
HostEnv. BuildHostFunctions assembles the host functions a plugin gets based
on its declared permissions. Whoever embeds the runtime fills in HostEnv
with real data. Four hosts do this today:
| Host | HostEnv is backed by |
Used for |
|---|---|---|
host-runtime/main.go |
a hardcoded simulated stream | demo/playground |
cmd/owncast-plugin-serve |
in-memory dev stubs + a dev chat log | local plugin development |
plugin/testing (MockHost) |
scenario-supplied fixtures | owncast-plugin-test |
Owncast pluginhost |
real Owncast services | production |
All four expose the same host functions and types. Only the data behind
HostEnv differs. That's what lets a plugin built once run identically in tests,
the dev server, and production.
The plugin-facing API exists in three representations that must agree:
- Go, the host functions, permissions, and types in Owncast's
services/plugins/hostfns.go. The runtime lives in the Owncast repo (see Relationship to Owncast), and this SDK imports it. - TypeScript, the
owncast.*wrappers insdks/js/index.jsand the types insdks/js/index.d.ts, which authors code against. services/plugins/plugin-contract.json, a generated snapshot of (1): permission identifiers, host-function names, and the field shapes of every wire type. It does nothing at runtime. It's a fingerprint.
services/plugins/contract_test.go guards against drift: it re-derives the
snapshot from hostfns.go and compares it to plugin-contract.json (field
shapes included). Regenerate after an intentional change with
UPDATE_CONTRACT=1 go test ./services/plugins/ -run TestPluginContractMatchesSDK.
The snapshot is the artifact this SDK and other consumers vendor, so an embedded runtime can't silently fall behind. See the Wire Protocol for the byte-level contract these three representations encode.
There are two separate builds: the engines (built rarely, by maintainers) and an author's plugin (built often, by anyone, with no wasm toolchain needed).
Because the interpreter is host-side, an author's build just produces their source, not a wasm module:
- JS (
sdks/js/bin/owncast-plugin.js):owncast-plugin buildruns esbuild to bundlesrc/plugin.{ts,js}into a single CommonJS file with@owncast/plugin-sdkmarked external (the SDK lives in the engine), emitting<slug>.js. - Python (
sdks/python): the build strips the SDK import line and emits<slug>.py(the SDK is a global in the engine).
owncast-plugin package then zips the manifest + that code file + public/
(web-served) + assets/ (host-read for manifest-inlined content), plus optional
icon.png / INSTRUCTIONS.md, into a single .ocpkg. The code entry is named
by language (plugin.js, plugin.py, or plugin.wasm for a
self-contained module), and the host infers the runtime from that filename,
so the manifest needs no type field. Authors no longer touch extism-js/
extism-py or binaryen at all.
engines/build.mjs and engines/build_py.py compile the fixed bootstrap entry
(SDK runtime + dispatch shim + a loader that reads the plugin's source from
config) into engine.wasm per language, via extism-js/extism-py +
binaryen, and copy the result into Owncast's embed dir
(services/plugins/engines/{javascript,python}/engine.wasm, committed so
Owncast's Go build stays toolchain-free). make fetches the engine toolchain
itself (engines/install-toolchain.mjs → engines/.toolchain/). The
author-facing SDK install does not ship any wasm tooling. Rebuild + recommit
when the SDK runtime or bootstrap changes. Full runbook in
engines/README.md.
The npm postinstall (sdks/js/scripts/postinstall.js) fetches just the
owncast-plugin-test / owncast-plugin-serve Go binaries (built from
host-runtime/) for test/serve. That's all an author's install downloads.
tools/bootstrap.sh builds them locally. .github/workflows/release.yml
cross-compiles those two (pure Go, CGO_ENABLED=0) for linux/darwin ×
amd64/arm64 on every v* tag.
owncast-plugin build/package, bundle a plugin's source into a.ocpkg.owncast-plugin test, run__tests__/*.test.jsonscenarios against a built plugin (delegates to theowncast-plugin-testGo binary, which uses the real runtime withMockHost).owncast-plugin serve, run one plugin behind a localhost dev server (owncast-plugin-serve), with stubbed host data and dev endpoints to drive chat/events into the plugin.
- Scenario tests (
*.test.json) describegivenstate,events/httpsteps, andexpectassertions. The runner loads the plugin on the real embedded engine withMockHost, so passing here means the same code path passes in production. - Go tests cover the runtime packages (
manager,dispatcher,server,sse,testing). - Contract/drift tests keep the Go/TS/snapshot representations aligned.
The runtime lives in the Owncast repo as services/plugins/, where Owncast
wires HostEnv to its real services. This SDK's host-runtime/ module imports
it, so the dev CLIs run the exact production runtime. The API surface in
hostfns.go is pinned by services/plugins/plugin-contract.json and its
contract test, so it can't drift from the Wire Protocol.
The host-side integration details (wiring, the sync workflow) are documented in
the Owncast repo at docs/plugins.md.