Skip to content

Plugin‐System

Yakuda edited this page Aug 10, 2026 · 1 revision

Plugin System

Plugins add their own line, their own values and their own settings card to the app. They are plain Python — no build step, no packaging, no compilation.

The full technical reference lives in the repository at docs/PLUGIN_API.md. This page is the practical version.

Current API version: 2 (core.plugins.PLUGIN_API_VERSION)


Architecture

Where a plugin lives

~/.config/OSC-DreamChatbox/plugins/<id>/
├── plugin.json      the manifest
├── main.py          the code
├── logo.png         optional, shown in the store
└── configs/         created by the app — never ship this

On Windows: %APPDATA%\OSC-DreamChatbox\plugins\<id>\

configs/ is the plugin's own writable folder. It survives an update from a newer .zip, which is exactly why a plugin must not ship one: the installer keeps the existing folder only when the archive has none.

How it is loaded

  1. On start, the app scans the plugins folder.
  2. Each plugin.json is read. id must match the folder name and the [a-z0-9_-] rule — it becomes a Python module name.
  3. If the manifest declares an api version higher than the app speaks, the row is greyed out with the reason and nothing is imported.
  4. The file named in main is imported and setup(api) is called.
  5. On every chatbox frame the app calls the hooks it needs.

A hook that raises is caught, logged, and shown in the plugin's info popup. It can never take the chatbox down.

What a plugin can contribute

Contribution Hook Appears as
One line of text get_text() {<id>}
Several lines get_lines() appended to the payload
Named values get_values() {<id>_<key>}
Its own UI build_widget() embedded in the plugin card
A filter on the final text on_text() last chance before sending

Plugin lines are anchored: each plugin picks which app its lines sit above (status, media, hardware or aio).


Writing a plugin

1. Create the folder

mkdir -p ~/.config/OSC-DreamChatbox/plugins/hello
cd ~/.config/OSC-DreamChatbox/plugins/hello

2. Write the manifest

plugin.json:

{
  "name": "Hello",
  "id": "hello",
  "version": "1.0.0",
  "author": "you",
  "main": "main.py",
  "summary": "One line for the plugin store",
  "description": "A longer explanation shown in the info popup.",
  "enabled": false,
  "api": 2,
  "is_linux": true,
  "is_windows": true,
  "template": "👋 {hello_who}",
  "placeholders": { "who": "who is being greeted" },
  "settings": [
    { "key": "who", "type": "text", "label": "Greet", "default": "world" },
    { "key": "loud", "type": "bool", "label": "Shout", "default": false }
  ]
}

3. Write the code

main.py:

"""Hello - the smallest useful plugin."""

_api = None


def setup(api):
    """Called once after import. Keep the api object."""
    global _api
    _api = api
    _api.log("hello plugin ready")


def teardown():
    """Called on disable and on exit. Stop threads here."""
    pass


def get_values():
    """Values for {hello_<key>} placeholders.

    Return None - not "" - for anything unknown. A None placeholder is
    dropped together with its separators.
    """
    who = _api.get("who", "world")
    if _api.get("loud", False):
        who = who.upper()
    return {"who": who or None}


def on_settings(opts):
    """The user changed a setting."""
    _api.log(f"settings changed: {opts}")

4. Enable it

Restart the app (or use the reload button), open the Plugins page and switch the row on. The settings from the manifest appear as a card.

5. Use the value

👋 {hello_who}

in an All-in-one string, or as a Placeholder block on the Advanced canvas.


Hooks

All optional. Implement what you need.

Hook When Purpose
setup(api) after import store the api object
teardown() on disable and exit stop threads, close handles
get_text() per frame str → {<id>}
get_lines() per frame list[str] appended to the payload
get_values() per frame dict → {<id>_<key>}
on_tick() per frame, first cheap polling without a thread
on_settings(opts) on change react to a setting
on_text(text) before sending last-chance filter on the final text
on_event(name, data) as announced app.shutdown, more to come
build_widget(parent) on card build your own QWidget

Threading rules

  • on_tick() runs on the GUI thread. Anything that can block — network, subprocess, a file on a slow mount — belongs in a thread.
  • Build widgets only in build_widget(), and never touch one from a worker thread. A Qt widget touched from another thread is a segfault, not an exception. Poll from a QTimer on the GUI thread instead.
  • Import Qt lazily, inside build_widget() — not at module level.

The api object

Member Since What
api.log(msg) 1 into the debug console, prefixed with your id
api.get(key, default) 1 one of your declared settings
api.settings 1 the live dict
api.plugin_dir 1 your folder
api.data_dir 1 configs/, writable, survives updates
api.host 1 the MainWindow, or None headless
api.app_name / api.app_version 1
api.api_version 2 what the app speaks
api.supports(feature) 2 feature detection
api.set(key, value) 2 write one of your settings, persisted
api.refresh() 2 ask for a fresh chatbox render
api.data_path(*parts) 2 a path inside data_dir, parents created

Supporting several app versions

Check the feature, not the version:

def setup(api):
    if api.supports("api.set"):
        api.set("binary", found)
    else:
        _session_only = found

Declare "api": 2 only when the plugin genuinely cannot work without it. An app that speaks less refuses to import and greys the row out — correct for a hard requirement, needlessly hostile for something you could have feature-detected.


Settings schema

Types: text bool int slider choice group path emoji action label.

{"key": "mode", "type": "choice", "label": "Data source",
 "default": "keyless",
 "choices": [{"value": "keyless", "label": "Keyless"},
             {"value": "api", "label": "Official API"}]}

{"key": "token", "type": "text", "label": "API key", "secret": true,
 "depends": "mode", "depends_value": "api"}

{"key": "twitch", "type": "group", "label": "Twitch", "expanded": false,
 "items": [ "...more settings..." ]}
  • depends hides a row while the named setting is falsy; depends_value compares against a value or a list instead.
  • secret masks the input. It is shoulder-surfing protection, not encryption — the value sits in config.json as plain text. Say so in the hint.
  • Keys are unique across the whole schema, groups included.
  • Groups nest two levels deep.

Forward compatibility

Nothing is silently dropped, in either direction:

Written by a newer app What an older app does
a settings row of an unknown type keeps the row and its default; api.get() works; the UI shows a disabled 🔒 line
extra keys on a settings row kept in item["extra"]
extra keys in plugin.json kept in Plugin.extra
extra keys in configs/config.json kept and written back untouched

So a plugin may ship options this app cannot edit yet and still rely on their values today, and a downgrade does not delete settings.


Installing and testing

From the plugin store

The Plugins page has a store that syncs a catalogue from GitHub. Install, update and remove happen there.

By hand

cd ~/.config/OSC-DreamChatbox/plugins/
unzip ~/Downloads/myplugin.zip

The folder name must match the id in the manifest.

Testing

  1. Switch Debug on under Options — plugin api.log() output appears in the console.
  2. Use the reload button on the Plugins page rather than restarting.
  3. Watch the preview column: a value returning "" instead of None shows up as a stray separator.

Checklist before publishing

  • teardown() stops every thread and child process you started
  • no Qt import at module level, only inside build_widget()
  • no widget touched from a worker thread
  • get_values() returns None, not "", for unknown values
  • no configs/ folder in the .zip
  • "api" declared only for hard requirements
  • the plugin still loads with every setting at its default

OSC-DreamChatbox · GPL-3.0-or-later · github.com/yakuda-stack/OSC-DreamChatbox

Clone this wiki locally