--- title: "Guide: sdk" description: "smabar plugin reference: Guide: sdk" canonical: "https://smabar.com/docs/plugin-ref-guide-sdk/" lang: en --- # Guide: sdk > smabar plugin reference: Guide: sdk Canonical HTML version: https://smabar.com/docs/plugin-ref-guide-sdk/ · Language: en · Publisher: smabar (https://smabar.com) --- [Build my plugin](https://smabar.com/build-my-plugin.md) · [All references](https://smabar.com/docs/plugin-ref-index.md) · [Install the ZIP](https://smabar.com/docs/install-plugin-zip.md) Source: smabar 1.5.0, revision `a019f2563965edc37a5e8d84742ea1ff2fa6f824`. [Export metadata and checksums](https://smabar.com/plugin-reference/export.json). This is an offline reference. MCP calls quoted below describe the connected workflow; use the linked documents and create local files instead. Live state, installed plugins, available providers and the user's active theme are unknown. Never claim a live check. Read with [styling](https://smabar.com/docs/plugin-ref-styling.md) and [examples](https://smabar.com/docs/plugin-ref-examples.md). ## import ```json "from smabar_sdk import Plugin — the SDK is on the path automatically; never vendor or pip-install it." ``` ## render ```json "app.render(tile_id, target, html, ttl_ms=None) — target is \"tile\", \"flyout\", \"hover\" or \"popup\". Rendering into a tile id your manifest does not declare is DROPPED and logged. For popup, ttl_ms=None is intentionally sticky; an invalid ttlMs that is not an unsigned 32-bit integer falls back to 5000 ms and is logged once instead of creating a sticky popup. Thread-safe: call app.render directly from a worker thread after protecting any shared state of your own." ``` ## on_ready ```json "@app.on_ready — handler() that runs ONCE, right after initialize. Put your FIRST render here. Rendering at import time (before app.run()) is the classic mistake: locales and settings do not exist yet, so t() emits raw keys and app.settings is empty — the tile then shows untranslated text and default values forever. The SDK warns in the plugin log when you do it." ``` ## every ```json "@app.every(seconds) — periodic handler. Starts right after initialize, so the first run happens immediately. Use @app.on_ready when you only need the first render, not a schedule." ``` ## on_action ```json "@app.on_action(tile_id, action=None) — handler(action, value) for clicks on [data-action] elements, context-menu entries and form submits. action=None matches every action of that tile. value is the element's data-value string when it has one, otherwise a dict of every data-field control in that surface ({name: value}, checkboxes as \"true\"/\"false\"). For a structured payload put JSON into data-value (html-escaped) and json.loads it in the handler. Nested [data-action] elements are fine: the innermost one under the pointer wins, so a row with data-action=\"select\" can hold its own remove button." ``` ## on_provider ```json "@app.on_provider(kind, interval_ms=0) — subscribe to advertised core data: \"cpu\", \"memory\", \"disk\", \"network\", \"battery\", \"media\", or \"audio\". A provider unavailable on this platform is skipped and logged once; render an empty/unavailable state from @app.on_ready. The handler gets a dict; no polling code of your own. Battery data is {\"batteries\": [...]} and may contain multiple entries. An empty batteries list means no battery is currently present; this is normal data, not an error, so hide battery UI without inferring the operating system. For each entry, chargePercent and healthPercent are percentages; cycleCount is an integer count or null; state is one of \"charging\", \"discharging\", \"full\", \"empty\", or \"unknown\"; isCharging is a boolean; timeTillEmpty and timeTillFull are milliseconds (ms) or null; powerConsumption is watts; voltage is volts. Media data is {currentSessionId, sessions}; each session has id, identity, desktopEntry, playbackState, title, artists, album, artUrl, positionMs, durationMs and canControl/canPlay/canPause/canGoNext/canGoPrevious. Audio data is {defaultOutput: {name, volumePercent, muted} | null}; null means no output device is currently available. These bounded strings are plain external data: html.escape() them before interpolation." ``` ## provider_action ```json "app.provider_action(kind, action, **args) — blocking provider command. Media actions are exactly \"play\", \"pause\", \"playPause\", \"next\", and \"previous\"; pass sessionId= to target one player, or omit it to act on currentSessionId. Audio actions are \"setVolume\" with integer volumePercent=0..100 and \"setMuted\" with boolean muted. Call this from a data-action handler; failures raise RpcError and are also caught/logged by the normal handler boundary." ``` ## on_settings_changed ```json "@app.on_settings_changed — handler(settings) after every settings change, from the settings UI or MCP." ``` ## settings ```json "app.settings — the current settings dict, kept up to date after every change." ``` ## get_settings ```json "app.get_settings() — re-read the settings from the core (blocking request); app.settings is normally enough." ``` ## set_settings ```json "app.set_settings(dict) — persist new settings, e.g. after the user submits a form in your flyout. It REPLACES the whole settings object, so always write app.set_settings({**app.settings, \"key\": value}); a bare {\"key\": value} silently erases every other setting. The core answers with settings.changed, so your @app.on_settings_changed handler runs once afterwards — render there instead of also rendering after the call, and compare the new settings with what you just wrote when that handler fetches network data, or you fetch twice." ``` ## data_dir ```json "app.data_dir — the writable directory your plugin owns (~/.smabar/data//). Caches, SQLite files, downloads and state go HERE, never in ~/.cache, /tmp or next to your code. Writing here does NOT restart the plugin. It is created before your first handler runs and deleted together with the plugin." ``` ## plugin_dir ```json "app.plugin_dir — your own code folder (~/.smabar/plugins//), read-only. Bundled files such as locales/ live here. Writing into it restarts the plugin, which is exactly what hot reload is for — so write to app.data_dir instead." ``` ## language ```json "app.language — the app's UI language code (\"en\", \"de\"). Delivered at initialize; a language change reaches the plugin on its next start." ``` ## t ```json "app.t(key) — translated string. Looks in /locales/en.json overlaid with locales/.json, then the app locale, then returns the key itself. Ship locales/en.json as a flat {\"key\": \"text\"} map (write it with plugin_write_file like any other file) and add locales/de.json for German; keys are yours, a plugin prefix such as \"weather.title\" keeps them out of the app locale's way. Placeholders are plain text you replace yourself: t(\"weather.places\").replace(\"{n}\", str(n))." ``` ## locale ```json "app.locale — the locale map handed over by the core." ``` ## log ```json "app.log(level, message, **fields) — \"debug\", \"info\", \"warn\" or \"error\". This is what plugin_logs returns; write messages you would want to read while debugging. Thread-safe: call app.log directly from a worker thread. print() also lands there, but without level or fields and without the RPC write lock; once your plugin uses threads, use app.log everywhere instead of print()." ``` ## plugin_id ```json "app.plugin_id — your own id, available after initialize." ``` ## run ```json "app.run() — serves the RPC loop until the core shuts the plugin down. Call it LAST. Put it behind `if __name__ == \"__main__\":` so tests can import your module." ``` ## command ```json "@app.command(name, description=..., input_schema={...}, output_schema={...}) registers a command before run(). The handler receives an arguments dict and returns a JSON object after persistence. Validate runtime inputs yourself; schemas describe the contract, they are not a validator. Raise ValueError for invalid arguments or RpcError(code, message) for an expected failure. Commands run on the shared handler worker while the RPC reader remains free. Agents discover them with plugin_commands(id), then await plugin_call(id, command, arguments). Timeout/disconnection means outcome unknown: use persistent requestId deduplication and revisions for writes." ``` ## audio ```json "app.audio.play(playback_id, {\"root\":\"plugin\"|\"data\", \"path\":\"relative/file.wav\"}, volume=100, loop=False). Local WAV/MP3/Ogg Vorbis/FLAC, no external player or visible HTML required. Same id replaces playback. pause(id), resume(id), stop(id), set_volume(id, 0..100), seek(id, position_ms), status(id); @app.audio.on_event receives audio.event dicts with playbackId, state, positionMs, optional durationMs/error. 4 active per plugin, 32 total. Stops when the plugin stops. Unsupported features or inaccessible/corrupt sources return errors; device failure requires a new play. Master output and notification sounds live in Settings > System > smabar audio; plugin volume and mute are in Settings > Plugins > Plugin settings > Settings & audio." ``` ## popups ```json "app.popups.show(tile_id, popup_id, html, ttl_ms=None, sound=None) updates the same stable popup or inserts a new one. sound is a local audio source {root:plugin|data,path:...}; it plays once on first display, never on HTML update. app.popups.dismiss(tile_id,popup_id) closes only this plugin session's popup. Requests return accepted/suppressed/dismissed; accepted does NOT mean displayed. @app.popups.on_event receives tileId,popupId,instanceId,state (shown,dismissed,expired,dropped,suppressed),reason. data-action buttons work unchanged; commit domain data before dismissing. Keep durable reminder state in app.data_dir, since popups are ephemeral and bounded. Existing app.render(...,\"popup\",...) remains supported." ``` ## capabilities ```json "app.capabilities is a frozenset of host capabilities, populated at initialize (commands, popups, audio); missing on older hosts means empty. Check it before optional desktop calls." ```