Extension Development Guide
Saba-chan's 8 actual hooks and the manifest.json spec, plus implementation notes from the bundled extensions.
Extension Development Guide
An extension is a piece of functionality that isn't tied to a specific game and instead hooks into the daemon's lifecycle globally. The Saba-chan daemon hands control over to enabled extensions at the following 10 points. Docker isolation, automatic SteamCMD installation, the UE4 INI parser, Discord music — all of them ride on top of these hooks.
The contents of this document are based on the actual manifest.json files in the saba-chan-extensions repository and the daemon-side dispatchers (the src/supervisor/extension_manager.rs family).
1. File Structure
%APPDATA%\saba-chan\extensions\<extension_id>\
├── manifest.json ← required. Declares hooks, dependencies, settings
├── <python_modules>.py ← Files referenced by manifest.json's python_modules
├── gui/ ← optional. Built-in GUI bundle (HTML/CSS/JS)
│ └── src/
├── i18n/
│ ├── en.json
│ └── ko.json
└── tests/ ← recommended
2. manifest.json Specification
A summarized example from the real Docker extension's manifest:
{
"id": "docker", // lowercase ASCII; must match the folder name
"name": "Docker Isolation",
"version": "0.1.0",
"description": "Docker container isolation",
"author": "WareAoba",
"min_app_version": "0.1.0",
"dependencies": {
"steamcmd": ">=0.1.0" // dependency on another extension (SemVer)
},
"python_modules": {
"docker_engine": "docker_engine.py",
"compose_manager": "compose_manager.py"
},
"hooks": {
"daemon.startup": {
"module": "docker_engine",
"function": "ensure",
"condition": null, // always run
"async": false // synchronous (blocking)
},
"server.post_create": {
"module": "compose_manager",
"function": "provision",
"condition": "instance.ext_data.docker_enabled",
"async": true // background; can report progress
}
// … more hooks
},
"gui": {
"builtin": true, // bundled gui/ is provided
"slots": {} // extension slot definitions
},
"config_fields": {
"docker_host": {
"type": "string",
"default": "unix:///var/run/docker.sock"
}
}
}
Field summary:
python_modules— Registers the Python files that hooks will reference, under logical names. Files not registered here cannot be called from a hook.hooks.<event>module— A logical name declared inpython_modulesfunction— The function name inside that filecondition(optional) — A boolean expression as a string. Conditional execution can reference context fields likeinstance.ext_data.fooasync(optional) — Iftrue, runs as a background task and can report progress
dependencies— Other extensions this one depends on. The daemon checks them atinstalltime.gui.builtin— Iftrue, the daemon servesgui/as a sidebar panel. The bundle is loaded viaGET /api/extensions/:id/gui.config_fields— User-editable setting fields. The GUI persists them toextensionConfig.json.
3. The Actual 10 Hooks
Every hook is implemented as a Python function and receives a context dictionary as its first argument. The list below is curated from the actual dispatch_hook call sites in the daemon source (src/main.rs, src/supervisor/mod.rs, src/ipc/handlers/{server,instance,extension}.rs).
| Hook name | When it runs | Primary use cases | Async-capable |
|---|---|---|---|
daemon.startup | At daemon boot / right after an extension is enabled | Verify Docker socket connection, verify SteamCMD presence, verify music deps (yt-dlp/ffmpeg) | ✗ (synchronous) |
daemon.shutdown | Right before daemon shutdown | Clean up containers and audio streams | ✗ |
server.post_create | Right after an instance is created | Auto-download via SteamCMD, Docker provisioning (long-running) | ✓ (progress-reportable) |
server.pre_start | Right before a server starts | Verify networking/port occupancy, boot containers | ✗ |
server.post_stop | Right after a server stops | Sync container state, clean up temp files | ✗ |
server.status | When per-server status is queried | Container state and additional metadata | ✗ |
server.list_enrich | When the instance list is queried | Add container CPU/memory stats. TTL-cached (dispatch_hook_timed, 10-second timeout) | ✗ |
server.pre_delete | Right before an instance is deleted | Clean up containers/volumes, reclaim external resources | ✗ |
server.settings_changed | After instance settings are saved | Sync external systems based on changed settings (return value is ignored) | ✗ |
server.check_update | When an update check is requested | Look for new versions through external channels like SteamCMD | ✗ |
Hook names like
server.pre_create,server.pre_install,server.post_install,server.post_start,server.pre_stop,module.parse_settings, andmodule.write_settingsdo not exist. They were confused in older design documents; the current dispatcher only routes the 10 hooks above.
3.1 Context Object Example
def provision(ctx: dict) -> dict:
"""
Example ctx:
{
"instance": {
"id": "instance_1",
"name": "My Palworld",
"module_name": "palworld",
"installation_dir": "C:/Servers/Palworld",
"settings": {...},
"ext_data": {"docker_enabled": true}
},
"hook": "server.post_create",
"daemon_api_url": "http://127.0.0.1:57474"
}
Returns:
{
"success": true,
"updates": { "instance.ext_data.container_id": "abc123" }
}
"""
3.2 Progress Reporting from Async Hooks
Hooks marked async: true may take a long time. Following the same convention as modules, write the following on stderr and GET /api/provision-progress/<tracking_name> will surface it.
import json, sys
def report(step, total, percent, message):
sys.stderr.write("PROGRESS:" + json.dumps({
"step": step, "total": total, "percent": percent,
"message": message, "done": False
}) + "\n")
sys.stderr.flush()
On completion, the daemon automatically marks done: true and cleans up the tracker after 5 seconds.
3.3 The TTL Cache for server.list_enrich
Because this hook can be invoked on every 2-second poll from the GUI, the daemon applies a TTL cache. While the cache is valid, no Python process is spawned and the previous result is reused. The call goes through dispatch_hook_timed with a 10-second timeout, so design each call to finish quickly (ideally under 100 ms).
4. The Four Built-in Extensions
docker
- id:
docker - dependencies:
steamcmd - Python:
docker_engine.py,compose_manager.py - GUI:
builtin: true. Container CPU/memory gauges and provisioning UI. - config fields:
docker_host, etc.
music
- id:
music - dependencies: none. Note that
daemon.startupchecks foryt-dlp/ffmpeg. - Python:
music_deps.py - GUI:
builtin: true. Queue and player controls. - Discord voice channel playback is a collaboration between the bot process (Node.js) and the music server (a separate process).
steamcmd
- id:
steamcmd - Python:
steamcmd.py - hooks: Downloads run on
server.post_create, plus update checks/applies. - config fields:
app_id,anonymous,beta,platform, etc., per instance. - No GUI (SteamCMD output streams to the console).
ue4-ini
- id:
ue4-ini - Python:
ue4_ini.py - Purpose: Provides
GameUserSettings.iniparsing/writing utilities for UE4 games (Palworld, etc.) as a Python import for other modules to use. - This extension is mostly library-shaped rather than a heavy hook consumer.
5. Working with Extensions via the REST API
GET /api/extensions list installed
POST /api/extensions/:id/install install (if needed)
POST /api/extensions/:id/enable enable → run daemon.startup asynchronously
POST /api/extensions/:id/disable disable
DELETE /api/extensions/:id uninstall
GET /api/extensions/:id/config current config_fields values
PUT /api/extensions/:id/config update values
GET /api/extensions/:id/gui builtin GUI bundle (HTML/JS)
GET /api/extensions/:id/i18n/:locale locale JSON
Enabled/disabled state is persisted to extensions_state.json; setting values are persisted to extensionConfig.json.
6. Development Rules
- Extensions must not depend on daemon code either. Use only the context the daemon hands you.
- Use
os.path.joinfor paths. Write all artifacts and logs strictly under%APPDATA%\saba-chan. manifest.jsonis UTF-8. Do not include a BOM.conditionexpressions are evaluated by the daemon, so they must be side-effect-free pure booleans. Do not call functions inside them.- An async hook must report
done: trueon completion; otherwise the GUI's progress indicator never closes. Design the reporting in afinallyblock so it's emitted even in error paths.
Next: REST API Reference