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 in python_modules
    • function — The function name inside that file
    • condition (optional) — A boolean expression as a string. Conditional execution can reference context fields like instance.ext_data.foo
    • async (optional) — If true, runs as a background task and can report progress
  • dependencies — Other extensions this one depends on. The daemon checks them at install time.
  • gui.builtin — If true, the daemon serves gui/ as a sidebar panel. The bundle is loaded via GET /api/extensions/:id/gui.
  • config_fields — User-editable setting fields. The GUI persists them to extensionConfig.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 nameWhen it runsPrimary use casesAsync-capable
daemon.startupAt daemon boot / right after an extension is enabledVerify Docker socket connection, verify SteamCMD presence, verify music deps (yt-dlp/ffmpeg)✗ (synchronous)
daemon.shutdownRight before daemon shutdownClean up containers and audio streams
server.post_createRight after an instance is createdAuto-download via SteamCMD, Docker provisioning (long-running)✓ (progress-reportable)
server.pre_startRight before a server startsVerify networking/port occupancy, boot containers
server.post_stopRight after a server stopsSync container state, clean up temp files
server.statusWhen per-server status is queriedContainer state and additional metadata
server.list_enrichWhen the instance list is queriedAdd container CPU/memory stats. TTL-cached (dispatch_hook_timed, 10-second timeout)
server.pre_deleteRight before an instance is deletedClean up containers/volumes, reclaim external resources
server.settings_changedAfter instance settings are savedSync external systems based on changed settings (return value is ignored)
server.check_updateWhen an update check is requestedLook 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, and module.write_settings do 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.startup checks for yt-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.ini parsing/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.join for paths. Write all artifacts and logs strictly under %APPDATA%\saba-chan.
  • manifest.json is UTF-8. Do not include a BOM.
  • condition expressions 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: true on completion; otherwise the GUI's progress indicator never closes. Design the reporting in a finally block so it's emitted even in error paths.

Next: REST API Reference