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. id ue4-ini → folder ue4_ini). Otherwise the daemon doesn't load it and lists it under failed in GET /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 — With 1, unknown keys, hook names not in FIRED_HOOKS, and references to missing python_modules are errors. Without it (0), they are only warnings.
  • id, name, version — Required. version is 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 (or saba-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 in python_modules
    • function — the function name inside that file
    • condition (optional) — only the instance.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 in extensions.settings.<id> in config.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 async field on hooks and no manifest_url in 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 NameWhen It's CalledHow the Result Is UsedExecution
daemon.startupRight 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.shutdownRight before daemon shutdownLogged onlySynchronous
server.pre_startRight before a server startshandled: true replaces the default start (if success isn't true then, the start fails)Synchronous
server.stopRight before a server stopshandled: true takes over the stopSynchronous
server.statusWhen querying an individual server's statushandled: true returns that result as isSynchronous
server.list_enrichWhen listing instanceshandled: true fills status and extension_status. 10s timeout, 30s TTL cacheSynchronous
server.post_createRight after an instance is createdResult is not usedBackground
server.pre_deleteRight before an instance is deletedIf handled, waits briefly so file locks are releasedSynchronous
server.settings_changedAfter instance settings are savedResult is not usedSynchronous
server.check_updateWhen an update check is requestedThe first successful result becomes the responseSynchronous

The old name server.post_stop is read as server.stop. Names like server.pre_create, server.pre_install, server.post_install, server.post_start, module.parse_settings, and module.write_settings do not exist; using them in a schema_version = 1 manifest 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. Leave handled out 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 (SteamCMD class: 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_server directly via from 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 name ue4_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 docker and music extensions have been removed.

Modules can't import disabled extensions. The daemon's import guard blocks importing any extensions.<name> that isn't in SABA_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/:locale into the ext_<id> namespace.
Slot idLocationprops
ServerCard.badgeServer card badgeserver
ServerCard.headerGaugeServer card header gaugeserver
ServerCard.provisionServer card while provisioningserver, provisionProgress, onDismiss, t
ServerCard.expandedStatsExpanded server card statsserver, t
ServerSettings.tabServer settings tabserver, activeTab, setActiveTab, settings, onSettingsChange, t
AddServer.optionsAdd-server optionst

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: false and a reason) and exit code 0 too, and send logs and tracebacks to stderr.
  • Use only the instance.ext_data.<key> form for condition. 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 the SABA_EXTENSION_DATA_DIR environment variable.
  • Run tests with pytest from the repository root.

Next: REST API Reference