Extension Development Guide
Saba-chan's 10 actual hooks and the manifest.json spec, GUI slot injection, and implementation notes from the bundled extensions.
Extension Development Guide
An extension (plugin) is an add-on that isn't tied to a specific game and instead hooks into the daemon's lifecycle globally, or injects components into the GUI. The Saba-chan daemon hands control over to enabled extensions at the following 10 points (hooks). The bundled extensions today are SteamCMD install/update (steamcmd) and the UE4 INI parser (ue4-ini).
The contents of this document are based on the actual manifest.json files in the saba-chan-extensions repository and the daemon-side extension system (src/extension/mod.rs).
1. File Structure
%APPDATA%\saba-chan\extensions\<extension_id>\
├── manifest.json ← required. Declares hooks, dependencies, fields
├── __init__.py ← lets modules import it as extensions.<name>
├── <python_modules>.py ← files referenced by python_modules in manifest.json
├── icon.png ← optional
├── gui/ ← optional. GUI bundle (UMD) source and build output
├── i18n/ ← optional. Set with i18n_dir in the manifest (<locale>.json)
└── test_<name>.py ← recommended. pytest tests
- The folder name must match the manifest
id. The id with hyphens replaced by underscores is also accepted (e.g. idue4-ini→ folderue4_ini). Otherwise the daemon doesn't load it and lists it underfailedinGET /api/extensions. - An id may only contain letters, digits,
-, and_.
2. manifest.json Specification
The actual manifest of the SteamCMD extension:
{
"schema_version": 1, // with 1, unknown keys and never-fired hooks are errors
"id": "steamcmd", // matches the folder name
"name": "SteamCMD",
"version": "0.1.0",
"description": "Installs and updates game servers with SteamCMD",
"descriptions": { "ko": "SteamCMD를 사용한 게임 서버 설치 및 업데이트 자동화" },
"author": "WareAoba",
"min_app_version": "0.1.0",
"dependencies": [], // dependencies on other extensions (array or { "id": ">=x.y.z" })
"python_modules": {
"steamcmd": "steamcmd.py" // logical name → file path
},
"hooks": {
"server.check_update": {
"module": "steamcmd",
"function": "check_update",
"condition": "instance.ext_data.install_method_steamcmd"
}
},
"instance_fields": {
"install_method_steamcmd": { "type": "boolean", "default": false },
"steamcmd_app_id": { "type": "number", "optional": true },
"steamcmd_anonymous": { "type": "boolean", "default": true },
"steamcmd_beta": { "type": "string", "optional": true },
"steamcmd_platform": { "type": "string", "optional": true }
},
"permissions": { "network": true, "spawn_process": true }
}
Field summary:
schema_version— With1, unknown keys, hook names not inFIRED_HOOKS, and references to missingpython_modulesare errors. Without it (0), they are only warnings.id,name,version— Required.versionis used for update and dependency comparisons.description/descriptions— English description and per-language descriptions. The list API picks the one matching the GUI language.min_app_version— The extension can't be enabled or installed on an older Saba-chan.dependencies— Dependencies on other extensions (orsaba-core). Only>=version requirements are supported. Enabling checks that the dependencies are enabled; it does not enable them automatically.python_modules— Registers the Python files that hooks and commands reference, by logical name. Files not registered here can't be called by hooks.hooks.<hook name>module— a logical name declared inpython_modulesfunction— the function name inside that filecondition(optional) — only theinstance.ext_data.<key>form is supported. The hook runs only for instances where that value is true, a non-zero number, or a non-empty string.
instance_fields— Per-instance values this extension uses (extension_data). If any instance has a value set, disabling or removing the extension is refused (in_use).config_fields— Global settings fields for the extension. Values are stored inextensions.settings.<id>inconfig.json.gui—bundle(UMD bundle path),styles(CSS path). See section 5 below.cli— CLI slot declarations (cli.slots).commands— Commands added to the instance command list (name,label,risk,inputs,module,function,condition).priority— Order when several extensions have the same hook (lower runs first, default 100).i18n_dir— Translation file folder.permissions— Declares the risky capabilities the code uses (the same table as a module's[permissions]).
There is no
asyncfield on hooks and nomanifest_urlin the manifest. The extension list and updates come only from Saba-chan's signed release manifest.
3. The 10 Actual Hooks
Hook functions run the same way modules do. The daemon spawns python <file>.py <function name>, writes the context JSON to stdin, and reads the JSON on stdout (whole or last line) as the result. The exit code must be 0. The list below is the same as FIRED_HOOKS in the daemon code.
| Hook Name | When It's Called | How the Result Is Used | Execution |
|---|---|---|---|
daemon.startup | Right after daemon startup / right after an extension is enabled (that extension only) | success/message go to the init status (/api/extensions/init-status) | Background |
daemon.shutdown | Right before daemon shutdown | Logged only | Synchronous |
server.pre_start | Right before a server starts | handled: true replaces the default start (if success isn't true then, the start fails) | Synchronous |
server.stop | Right before a server stops | handled: true takes over the stop | Synchronous |
server.status | When querying an individual server's status | handled: true returns that result as is | Synchronous |
server.list_enrich | When listing instances | handled: true fills status and extension_status. 10s timeout, 30s TTL cache | Synchronous |
server.post_create | Right after an instance is created | Result is not used | Background |
server.pre_delete | Right before an instance is deleted | If handled, waits briefly so file locks are released | Synchronous |
server.settings_changed | After instance settings are saved | Result is not used | Synchronous |
server.check_update | When an update check is requested | The first successful result becomes the response | Synchronous |
The old name
server.post_stopis read asserver.stop. Names likeserver.pre_create,server.pre_install,server.post_install,server.post_start,module.parse_settings, andmodule.write_settingsdo not exist; using them in aschema_version = 1manifest makes loading fail.
3.1 Context and Entry Point
A hook function takes one context dictionary and returns a dictionary. Depending on the hook, the context contains instance_id, instance_name, module, instance_dir, extension_data, and so on, and every hook also gets the extensions' global settings as extension_config. The Python file must handle the function name and stdin itself in __main__. A shortened example of the structure of steamcmd.py:
import json, sys, traceback
def check_update(ctx: dict) -> dict:
"""
ctx example (server.check_update):
{
"instance_id": "…",
"instance_name": "My Palworld",
"module": "palworld",
"instance_dir": "…",
"install_dir": "…",
"app_id": 2394010,
"extension_data": {"install_method_steamcmd": true, ...},
"extension_config": {...}
}
"""
...
return {"success": True, "update_available": False}
FUNCTIONS = {"check_update": check_update}
if __name__ == "__main__":
name = sys.argv[1] if len(sys.argv) > 1 else ""
try:
raw = sys.stdin.read()
ctx = json.loads(raw) if raw.strip() else {}
result = FUNCTIONS[name](ctx) if name in FUNCTIONS else \
{"success": False, "message": f"Unknown function: {name}"}
except Exception as e:
traceback.print_exc(file=sys.stderr) # logs go to stderr
result = {"success": False, "message": str(e)}
print(json.dumps(result, ensure_ascii=False)) # failures are JSON + exit code 0 too
3.2 handled and Execution Order
- Extensions with the same hook run one at a time, ordered by
priority, then by id. - If a result contains
handled: true, the remaining extensions are skipped. Leavehandledout unless you are replacing that hook's default behavior. - If one extension fails, the error is recorded and the next extension runs.
- The default time limit is 120 seconds. The hook path has no progress (
PROGRESS:) display.
3.3 TTL Cache for server.list_enrich
This hook may be called for every instance that has extension_data, one after another, each time the instance list is requested, so the daemon caches successful results for 30 seconds. While the cache is valid, it reuses the previous result without spawning a Python process. Each call has a 10-second timeout, so design it to finish quickly. The result's status_line is a short one-line status shown in the Discord status display, so don't put paths or secrets in it.
4. The 2 Bundled Extensions
steamcmd
- id:
steamcmd - Python:
steamcmd.py(SteamCMDclass: portable SteamCMD install, game server install/update, update check) - Hook: only
server.check_update(condition: instance.ext_data.install_method_steamcmd). - The actual install is done by the module's
install_serverdirectly viafrom extensions.steamcmd import SteamCMD. - instance_fields:
install_method_steamcmd,steamcmd_app_id,steamcmd_anonymous,steamcmd_beta,steamcmd_platform. The daemon fills them from the module's[install]section using a generic rule (install_method_<method>,<method>_<field>). - No GUI.
ue4-ini
- id:
ue4-ini(folder nameue4_ini) - Python:
ue4_ini.py(parse_option_settings,write_option_settings) - Purpose: provides utilities to modules for reading and writing the INI
OptionSettings=(...)of UE4 games such as Palworld. - It's a pure library that declares no hooks; modules import it as
extensions.ue4_ini.
The former
dockerandmusicextensions have been removed.Modules can't import disabled extensions. The daemon's import guard blocks importing any
extensions.<name>that isn't inSABA_ENABLED_EXTENSIONS.
5. GUI Bundle (Slot Injection)
If manifest.json declares gui.bundle, the GUI fetches that file through GET /api/extensions/:id/gui and runs it (and also injects the CSS if gui.styles is set).
"gui": { "bundle": "gui/dist/index.umd.js", "styles": "gui/dist/style.css" } // example paths
- The bundle is in UMD format and must create the global
window.SabaExt<PascalCase id>. Remove-and_from the id and capitalize the following letter (e.g.steamcmd→window.SabaExtSteamcmd,ue4-ini→window.SabaExtUe4Ini). - When that global's
registerSlots()returns{ slotId: Component | Component[] }, the GUI mounts them into those slots. - React is referenced as an external from the host's
window.React. Components must work using only the props the slot passes in. - Translations are loaded through
GET /api/extensions/:id/i18n/:localeinto theext_<id>namespace.
| Slot id | Location | props |
|---|---|---|
ServerCard.badge | Server card badge | server |
ServerCard.headerGauge | Server card header gauge | server |
ServerCard.provision | Server card while provisioning | server, provisionProgress, onDismiss, t |
ServerCard.expandedStats | Expanded server card stats | server, t |
ServerSettings.tab | Server settings tab | server, activeTab, setActiveTab, settings, onSettingsChange, t |
AddServer.options | Add-server options | t |
server is the server info from /api/servers; components can read extension_data and extension_status[<extension_id>] (the server.list_enrich result).
6. Managing Extensions via the REST API
GET /api/extensions Installed list (+ load failures)
GET /api/extensions/init-status daemon.startup results
POST /api/extensions/rescan Re-read the folder
GET /api/extensions/manifest Remote catalog (signed release manifest)
GET /api/extensions/updates Update list
POST /api/extensions/:id/install Install (from the catalog, sha256 checked)
POST /api/extensions/:id/enable Enable → runs that extension's daemon.startup
POST /api/extensions/:id/disable Disable
POST /api/extensions/:id/mount Register in memory
POST /api/extensions/:id/unmount Unregister from memory
DELETE /api/extensions/:id Remove
GET /api/extensions/:id/config config_fields values
PUT /api/extensions/:id/config Update values
GET /api/extensions/:id/gui GUI bundle (JS)
GET /api/extensions/:id/gui/styles GUI styles (CSS)
GET /api/extensions/:id/icon Icon
GET /api/extensions/:id/audit Compare capabilities the code uses with permissions
GET /api/extensions/:id/i18n/:locale Locale JSON
The enabled list is stored in extensions.enabled in config.json, and global setting values in extensions.settings.
7. Development Rules
- Extensions must not depend on daemon code either. Use only the context and environment variables the daemon passes in.
- Build paths with
os.path.join; don't hardcode them. - Hook functions answer failures with JSON (
success: falseand a reason) and exit code 0 too, and send logs and tracebacks to stderr. - Use only the
instance.ext_data.<key>form forcondition. Any other form always evaluates to false. - Python dependencies use the daemon's shared environment. There's no per-extension requirements declaration.
- Unverified extensions (Windows): Extensions not downloaded from the signed catalog, or changed after download, are scanned with antivirus and then run inside an isolated AppContainer. They can't reach loopback (such as the daemon API), don't receive
SABA_TOKEN_PATH, and can only read instance folders. Put data you need to write in the dedicated folder pointed to by theSABA_EXTENSION_DATA_DIRenvironment variable. - Run tests with
pytestfrom the repository root.
Next: REST API Reference