Skip to main content

Plugin Manifest reference

Every plugin has a plugin.manifest.json file at its root. This is the source of truth for the plugin's identity, the permissions it needs, the network destinations it's allowed to call, the admin pages it contributes, and the action buttons it adds to the viewer UI.

Plugin manifests require Owncast v0.3.0

Plugins require Owncast 0.3.0 or later.

The manifest is what an admin reviews before installing the plugin. The host parses it at load time and enforces every declaration. Nothing in the compiled plugin can grant a capability the manifest didn't ask for.

Owncat informs youAvailable in every SDK

The manifest is plain JSON that describes the plugin to the host, independent of the language you wrote the code in. For the language-specific details, see the JavaScript or Python SDK reference.

Minimum manifest

{
"api": "1",
"name": "My Plugin",
"version": "0.1.0",
"description": "Short description for admins",
"permissions": []
}

api, name, and version are required. Everything else is optional and only needed when you use the corresponding feature.

Top-level fields

FieldTypeRequiredDescription
apistringyesManifest schema version. Currently "1".
namestringyesHuman-readable display name shown in admin lists and registry cards. Example: "Awesome Echo Bot".
slugstringnoCanonical identifier (URL prefix, config namespace, filename). Auto-derived from name if omitted. See below.
versionstringyesYour plugin's version. SemVer recommended. Informational metadata for admins and the registry: the host doesn't gate loading on it.
descriptionstringnoOne-sentence summary the admin sees in the plugin list and during install.
categorystringnoRegistry browse category. See category.
permissionsstring[]noList of capabilities your plugin needs. See Permissions.
configobjectnoAdmin-configurable settings your plugin reads at runtime. See Configuration.
botobjectnoChat-bot configuration. See bot.
networkobjectnoOutbound-HTTP allowlist, required when network.fetch is granted. See below.
actionsobject[]noAction buttons to add to the viewer UI. See UI: Action buttons.
adminobjectnoAdmin pages to add to the Owncast admin UI. See UI: Admin pages.
stylesstring[]noCSS files inlined into the viewer page. See styles.
scriptsstring[]noJavaScript files inlined into the viewer page. See scripts.
extraPageContentobjectnoAn object declaring a slug and an optional HTML file prepended to the viewer's extra-content block. See extraPageContent.
tabsobjectnoViewer-page tabs keyed by stable slug. See tabs.

name and slug

name is the human-readable display name. It can contain any characters, including spaces and punctuation, and is what admins see in the plugin list, what shows up on registry browse cards, and the default chat-bot identity.

slug is the canonical identifier. It controls:

  • The plugin's URL prefix: /plugins/<slug>/...
  • The config (key-value store) namespace
  • The filename of the built artifact (<slug>.ocpkg)
  • The primary key in the plugin registry

Slugs are lowercase letters, digits, and hyphens, starting with a letter, up to 64 characters. The SDK derives one from name automatically when slug is omitted: spaces and punctuation collapse into single hyphens, letters lowercase. "Awesome Echo Bot" becomes awesome-echo-bot. Pin slug explicitly when the auto-derivation isn't what you want, or when your display name uses characters outside ASCII ("Café Helper" would otherwise yield caf-helper).

Avoid changing the slug after release: the rename will look like a different plugin to admins, with a fresh config store. Changing name (display only) is safe. It doesn't change identity.

category: registry browse category

An optional label that places your plugin in a browse category on the registry and in the admin UI. The canonical values are chat-bots, chat-filters, moderation, authentication, themes, overlays, notifications, integrations, video, analytics, games, admin-utilities, examples, and other.

The SDK's packaging CLI warns when category isn't one of these, but nothing rejects it: the host and registry tolerate unknown categories, they just won't match any browse filter.

bot: chat-bot identity

Plugins that post to chat (using owncast.chat.send) appear under a chat-bot user. By default the bot shows up under the plugin's display name. Override that with bot.displayName:

{
"name": "Stream Sidekick",
"bot": {
"displayName": "Sidekick"
}
}

In chat, the bot posts as "Sidekick" instead of "Stream Sidekick". The first time the plugin loads, Owncast provisions a persistent chat user keyed on the plugin's slug (so the bot identity survives reinstalls and display-name changes).

bot.displayName is only relevant for plugins that have the chat.send permission. It's ignored otherwise.

config: admin-configurable settings

Declare typed settings here and Owncast renders an editable form for them in the admin, which your plugin reads at runtime with owncast.config.get. Each entry has a type (string, number, or boolean), 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" }
}
}

Config keys starting with __ are reserved: the host uses that prefix to inject per-instance state into the plugin runtime, and a manifest declaring one is rejected at load.

Full coverage, including how the form renders, credential masking, validation, and where overrides are stored, in Configuration.

permissions

Each entry unlocks a slice of host APIs. The host rejects calls to a method whose permission you didn't declare.

{
"permissions": ["chat.send", "storage.kv", "network.fetch"]
}

See the permissions reference for the full list of identifiers and what each one grants.

network: outbound HTTP allowlist

network.fetch is gated by an explicit allowlist of hostnames. If you declare network.fetch in permissions, you also need a network.allowedHosts field listing the hosts you'll call:

{
"permissions": ["network.fetch"],
"network": {
"allowedHosts": ["api.discord.com", "*.weather.com"]
}
}

Entries are hostname globs. Bare names like api.discord.com match exactly. * is a wildcard segment, so *.weather.com matches api.weather.com and data.weather.com but not weather.com itself or evil.com.

The wildcard "*" matches any host, but you must write it explicitly:

{
"network": { "allowedHosts": ["*"] }
}

This is intentional. Admins reviewing the manifest see the scope they're granting. Most plugins should list the specific hosts they call instead.

The host rejects the load if network.fetch is granted without an allowedHosts entry.

actions: action buttons

Action buttons are clickable entries Owncast surfaces under the stream. While your plugin is enabled, the host merges its entries into the list Owncast already shows.

{
"permissions": ["ui.modify", "http.serve"],
"actions": [
{
"title": "Chat Overlay",
"description": "Open the live chat overlay",
"url": "/",
"icon": "/star.png",
"color": "#3b82f6"
},
{
"title": "Issue tracker",
"url": "https://github.com/example/my-plugin/issues",
"openExternally": true
},
{
"title": "About this stream",
"html": "<p>Live every weekday at 8pm UTC.</p>"
}
]
}

Each entry:

FieldTypeNotes
titlestringRequired. The button label.
urlstringEither an absolute https://... URL or a path. Mutually exclusive with html.
htmlstringRaw HTML rendered in an inline modal. Mutually exclusive with url.
iconstringOptional image URL shown on the button. Same path rules as url.
colorstringOptional hex color for the button background.
descriptionstringOptional. Shown in the modal that opens for URL-based actions.
openExternallybooleanIf true, the URL opens in a new tab instead of an inline modal.

Rules the host enforces at load time:

  • ui.modify permission is required. Without it, the manifest is rejected.
  • Exactly one of url or html per entry.
  • Relative URLs (and icons) starting with / auto-prefix to your plugin's namespace. "/" becomes /plugins/my-plugin/. "/star.png" becomes /plugins/my-plugin/star.png. Saves you from hard-coding your plugin name.
  • URLs (and icons) that resolve into your namespace require http.serve, since you're the one serving them.
  • URLs (and icons) pointing at another plugin's namespace are rejected. Catches typos and prevents one plugin from advertising another's UI.

Full coverage in UI: Action buttons.

admin: admin pages

Plugins can register pages that appear in the Owncast admin UI under Plugins. The pages object is keyed by plugin-relative path glob:

{
"permissions": ["http.serve"],
"admin": {
"pages": {
"/admin": { "title": "Settings", "icon": "gear" }
}
}
}

Each entry has:

PartTypeNotes
object keystringRequired path glob under the plugin's namespace, such as "/admin" or "/admin/*".
titlestringRequired. The tab label shown in the admin UI.
iconstringOptional. A short semantic name (gear, wrench, user, and so on).

The host derives each page path from its object key. A key of "/admin" maps to /plugins/<your-slug>/admin. Requests matching any key are auth-gated by the host, so unauthenticated requests get a 401 before your plugin code runs.

JSON object order is not significant. Owncast displays admin pages in lexicographic path order. pages must be an object. Do not add a path member to a page value. The host rejects arrays and page values containing the legacy path member.

Full coverage in UI: Admin pages.

styles: CSS injection

A list of CSS files the plugin contributes to the viewer page. Each file's contents are inlined into the same <style> block Owncast already uses for the admin's custom CSS, so plugins can theme the page without each contribution needing its own <link> tag.

{
"permissions": ["ui.modify"],
"styles": ["theme.css", "overrides.css"]
}

Path rules match action-button URLs:

  • Bare paths like "theme.css" auto-prefix to your plugin's namespace.
  • Single-slash paths like "/theme.css" get the same treatment.
  • Fully qualified /plugins/<your-slug>/... paths pass through.
  • Paths in another plugin's namespace are rejected.
  • http:// and https:// URLs are rejected. Bundle external assets (fonts, images) and reference them with @font-face or url(...) from inside your CSS, so an admin reviewing the manifest sees every file that will land in their page.
  • Each entry must end in .css.

Requires ui.modify only (the plugin paints inside Owncast's chrome). http.serve is not needed: each file's bytes are read from assets/ and inlined into customStyles on /api/config, not served at a URL. The host emits a /* plugin: <your-slug> ... */ comment in front of each contribution so a reader can attribute a rule back to whichever plugin shipped it.

For CSS that depends on plugin state, an onPageStyles handler returns it at request time, with no manifest field. Its output appends to customStyles after these static files.

Full coverage in UI: Viewer stylesheets.

scripts: JavaScript injection

A list of JavaScript files the plugin contributes to the viewer page. Each file's contents are appended to the same response the admin's custom JavaScript already comes from (/customjavascript), so plugins can extend the page without each contribution needing its own <script> tag.

{
"permissions": ["ui.modify"],
"scripts": ["client.js"]
}

Path rules and required permissions match styles, applied to .js files (only ui.modify is needed, and the host reads from assets/ and inlines into /customjavascript). Wrap your script in an IIFE so top-level declarations don't collide with the admin's JavaScript or other plugins. The host emits a // plugin: <your-slug> ... comment in front of each contribution and wraps every contribution in a try/catch so one plugin's runtime error can't break the others.

For JavaScript that depends on plugin state, an onPageScripts handler returns it at request time, with no manifest field. Its output appends to /customjavascript after these static files.

Full coverage in UI: Viewer scripts.

extraPageContent: HTML block

An object that contributes an HTML block to the viewer's extra-content area, prepended above the admin's prose on /api/config.

{
"permissions": ["ui.modify"],
"extraPageContent": { "slug": "banner", "content": "content.html" }
}
FieldTypeNotes
slugstringRequired only when content is omitted (the host passes it to onPageContent). Optional otherwise. Lowercase letters, digits, and hyphens, starting with a letter.
contentstringOptional. Relative path to a static HTML file in assets/. When present, that file's bytes are inlined directly. When omitted, the host calls onPageContent instead.

Static (with content): the host reads the file at request time and inlines the bytes. Same path rules as styles and scripts, applied to a single .html entry. Plugin HTML bypasses the markdown processor so tags and attributes pass through as written.

Dynamic (without content): implement onPageContent({ slug, user? }) in your plugin to return HTML at request time. Use this when the content should vary per viewer or draw on live data (for example, personalised greetings or current stream stats). user is the viewer's chat identity, present when authenticated.

Requires ui.modify. http.serve is not required because the HTML is inlined into the config response, not served as a URL. Each contribution is wrapped with an <!-- plugin: <your-slug> ... --> comment so a reader can attribute the markup back.

Full coverage in UI: Extra page content.

tabs: viewer-page tabs

The tabs object contributes tabs to the viewer page's tab row next to the built-in About and Followers tabs. Each object key is the tab's stable slug. Every value requires title, and content is optional.

{
"permissions": ["ui.modify"],
"tabs": {
"music": { "title": "Music", "content": "music.html" },
"schedule": { "title": "Schedule", "content": "schedule.html" }
}
}

Each entry has:

PartNotes
object keyRequired stable slug. Lowercase letters, digits, and hyphens, starting with a letter. The host passes this key to onTabContent when content is omitted.
titleRequired. The label shown on the tab. Must be unique within the plugin's tabs.
contentOptional. Relative path to an HTML file under assets/. Same path rules as extraPageContent (auto-prefix to your namespace, cross-plugin paths and http(s):// URLs rejected, must end in .html). When omitted, the host calls onTabContent.

Within each plugin, Owncast displays tabs in lexicographic slug order. JSON object order is not significant. Ordering between tabs from different plugins is unspecified. tabs must be an object. Do not add a slug member to a tab value. The host rejects arrays and tab values containing the legacy slug member.

Requires ui.modify. http.serve is not required: each static tab's HTML is read from assets/ and inlined into the pluginTabs[] array on /api/config. For a dynamic tab, the host passes the object key to onTabContent as slug and inlines the returned HTML.

Full coverage in UI: Viewer-page tabs.

Manifest-to-runtime contract

When your plugin loads, the host parses the manifest and asks the runtime to register itself. It compares the two and rejects the load when:

  • the slugs don't match (slug is the canonical identity on both sides)
  • the runtime uses a permission that wasn't declared in the manifest

version is intentionally not compared. It's informational metadata the host gates nothing on, and the SDK bakes it into the registration from the same manifest at build time anyway.

You don't write the registration yourself: the SDK generates it from the handlers you define (see your SDK reference for how handlers are declared in your language). Knowing this contract exists is useful when debugging. A "permission requested at runtime not declared in manifest" error means you added a handler that needs a permission you forgot to list.

Complete example

A non-trivial manifest exercising most features:

{
"api": "1",
"name": "Stream Sidekick",
"slug": "stream-sidekick",
"version": "0.2.0",
"description": "Posts to Discord on stream start, shows an overlay, and adds a Donate button.",
"permissions": [
"chat.send",
"chat.filter",
"storage.kv",
"http.serve",
"http.sse",
"network.fetch",
"notifications.send",
"ui.modify"
],
"bot": {
"displayName": "Sidekick"
},
"network": {
"allowedHosts": ["api.discord.com", "*.example.com"]
},
"actions": [
{
"title": "Donate",
"url": "https://example.com/donate",
"openExternally": true
}
],
"admin": {
"pages": {
"/admin": { "title": "Sidekick settings", "icon": "gear" }
}
},
"styles": ["sidekick.css"],
"scripts": ["sidekick.js"],
"extraPageContent": { "slug": "intro", "content": "intro.html" },
"tabs": {
"schedule": { "title": "Schedule", "content": "schedule.html" }
}
}

Improve this page

See something missing or incorrect? Edit this page and improve the documentation for everyone.

Contributors to this documentation
Gabe KangasGabe Kangas
G
Gabe Kangas

Related Documents