Module Development Guide
How to build a Python module that adds a new game to Saba-chan. Covers the module.toml schema and the actual lifecycle.py function contract and protocol.
Module Development Guide (Python)
The Saba-chan daemon doesn't know game-specific details. Instead, each game is described by a Python module. Adding one module lets Saba-chan manage a new game.
This document is based on the real modules in the saba-chan-modules repository (minecraft, palworld, zomboid), the annotated template __example, and the daemon code.
1. Module File Structure
%APPDATA%\saba-chan\modules\<module_name>\
├── module.toml ← required. Declarations (schema v1)
├── lifecycle.py ← required. Entry file named by [module].entry
├── icon.png ← optional. Set with [module].icon
├── locales/
│ ├── en.json ← reference language
│ ├── ko.json
│ └── ... ← UI strings
└── test_lifecycle.py ← optional. pytest unit tests (recommended)
- Name the folder the same as
[module].nameinmodule.toml, in lowercase English. - The daemon only reads first-level folders under the modules root that contain a
module.toml, and it won't load a module whose[module].entryfile is missing. - To start, we recommend copying
__example/__example__module.tomltomodule.tomland__example/__example__lifecycle.pytolifecycle.py, then deleting what you don't need.
2. module.toml Specification
A summarized example from the top of the real minecraft/module.toml:
[module]
schema_version = 1 # required. Unknown keys or invalid values make loading fail
name = "minecraft" # same as the folder name
version = "0.1.0"
display_name = "Minecraft"
game_name = "Minecraft"
entry = "lifecycle.py" # Python file the daemon runs
icon = "icon.png"
log_pattern = '^\[\d{2}:\d{2}:\d{2}(?:\] \[[^\]]*/| )(?P<level>INFO|WARN|ERROR|DEBUG|FATAL)\]'
[module.dependencies]
saba-core = ">=0.1.5" # component → minimum version (">=" only)
[permissions]
network = true # capabilities the code uses
spawn_process = true
[protocols]
supported = ["stdin"] # from "stdin" / "rest"
default = "stdin"
interaction_mode = "console" # "console" or "commands"
[config]
server_executable = "server.jar"
process_name = "java.exe"
default_port = 25565
announce_command = "say" # [[commands.fields]] name used for announcements
# stop_command defaults to "stop" when omitted
[backup]
paths = ["{level_name}", "{level_name}_nether", "{level_name}_the_end"]
save_command = "save-all"
[install]
method = "download" # "manual" (default) / "download" / an extension id (e.g. "steamcmd")
[detection]
dir_signatures = ["server.jar"]
graceful_exit_patterns = ["Stopping the server"]
crash_artifacts = ["crash-reports/*.txt", "hs_err_pid*.log"]
What each section means:
[module]— Metadata.name,version, andentryare required.log_patternextracts the log level of a console line through a(?P<level>…)group. Keys in[module.dependencies]aresaba-core,ext-<extension id>, and so on.[permissions]— Declares the risky capabilities the code uses (network,spawn_process,native_code,dynamic_code,env_secrets,fs_outside_instance).[protocols]—supportedonly takes"stdin"and"rest". For security, Saba-chan does not use RCON, and modules turn RCON off when the game has an RCON setting.interaction_modedecides whether to show a console view (console) or only command buttons (commands).[config]—server_executable,executable_path,process_name,default_port,stop_command(stop command sent to the console, defaultstop),stop_timeout(seconds, default 30),announce_command. Game settings go in[[settings.fields]], not here.[[settings.fields]]— Items on the instance settings screen (name,type,label,default,options,group, etc.). Values the user changes are stored as instance settings and arrive in the config of each function call.[[commands.fields]]— Game commands. Withmethod = "console"(+console_template) the command goes to the console (stdin), with"rest"to the game's REST API, and with"module"(default) tocommand()inlifecycle.py.risk(safe/caution/danger) anddescriptionare read by AI command interpretation.[install]— Install method,app_id,requires_extensions(list of required extension ids), etc. Palworld requires["steamcmd", "ue4-ini"].[detection]— Recognizing existing server folders (dir_signatures), finding processes (process_patterns,cmd_patterns), and crash detection (graceful_exit_patterns,crash_artifacts). Only exits judged as crashes are restarted automatically.[backup]— Paths to include in backups (paths), the command to run right before a backup (save_command), andhook = truewhen the paths are computed in code.[credential_map]— Maps daemon standard keys to game setting keys. Palworld example:rest_password = "AdminPassword".- There are also
[launch],[[agreements]],[[presets]],[[queries]],[aliases],[errors],[[syntax_highlight.rules]], and[extensions.<id>]. For the full key list, seedocs/module-spec.mdin thesaba-chan-modulesrepository.
The old
[update]section no longer exists. Module updates are delivered through Saba-chan's signed release manifest.
3. lifecycle.py Function Contract
Whenever it needs to, the daemon spawns a new Python process to call one function in this file. Every function takes a single config: dict and returns a dict, and must be registered in the FUNCTIONS table at the end of the file. Modules have no start function: the daemon launches the server itself from the result of get_launch_command.
3.1 Required Functions
The only required function is get_launch_command.
import os
from daemon_rcon import config_value, run_lifecycle
def get_launch_command(config: dict) -> dict:
"""Return how to launch the server. The daemon spawns the process."""
exe = config.get("server_executable", "")
if not exe or not os.path.exists(exe):
return {"success": False, "message": f"Server executable not found: {exe}"}
port = config_value(config, "port", 7777)
return {
"success": True,
"program": exe,
"args": [f"-port={port}"],
"working_dir": config.get("working_dir") or os.path.dirname(exe),
"env_vars": {},
}
FUNCTIONS = {"get_launch_command": get_launch_command}
if __name__ == "__main__":
run_lifecycle(FUNCTIONS)
- The daemon spreads the instance settings (
module_settings) into the top level of config. Reading them withconfig_value(config, key, default)is the safe way. - If config contains
extra_args(launch arguments the user added), place them where they belong and returnextra_args_placed: true. Otherwise the daemon appends them to the end ofargs. preview: truemeans a launch-argument preview. Don't write settings files; just return the launch information.
3.2 Optional Functions
Implement only the features you support, and if you implement one, always add it to the FUNCTIONS table.
| Function | When the daemon calls it | Returns |
|---|---|---|
validate | Pre-start check | success, issues[] (the server won't start if any has severity: "critical") |
stop | Stopping servers without a console, and non-managed servers | success |
status | Non-managed status query | status, pid |
command | Commands with method = "module" | success, message |
get_credentials | When REST connection info is needed | rest_username, rest_password |
configure / read_properties / reset_properties / reset_server | Settings screen save / read / reset | success (+ values read) |
import_settings | Importing an existing server, reading settings | settings{} |
list_versions / get_version_details | Version list / details | Version info |
install_server | Install and update | success, executable_path, install_path |
get_installed_version | Installed version query | version |
accept_agreements | After the user accepts [[agreements]] | success |
diagnose_log | Log diagnosis | success |
backup_paths | Backups (when [backup] hook = true) | success, paths[] |
instance_commands | Command list (when [commands] hook = true) | success, commands[] |
Common rules:
- Every response includes
success: boolandmessage: str. If user action is needed, includeaction_required: "<code>". - If a required extension is missing, answer with
error_code: "extension_required"andmissing_extensions: [...]. - For an unknown function name, answer
{"success": false, "message": "Unknown function: X"}(run_lifecycle()handles this).
For a managed server with a console, the daemon stops it by sending
[config].stop_commandto the console (stdin) and waiting. The module'sstop()is only called when there is no console; it must follow the order graceful stop request → wait → force kill, and only touch thepidthe daemon passed in.
4. Subprocess Protocol
How the daemon calls module functions is implemented in src/plugin/mod.rs of the Saba-chan repository. In summary:
- It spawns
python lifecycle.py <function name>, using the Python environment managed by the daemon. - Injected environment variables:
PYTHONIOENCODING=utf-8,PYTHONUNBUFFERED=1PYTHONPATH— the modules folder holding the shared files (i18n.py,daemon_rcon.py) and the parent of the extensions folderSABA_IPC_URL,SABA_IPC_PORT— daemon API addressSABA_TOKEN_PATH— path of the file holding the daemon API tokenSABA_LANG— GUI languageSABA_ENABLED_EXTENSIONS— comma-separated ids of enabled extensions (e.g.steamcmd,ue4-ini)
- It writes one config JSON object to stdin and closes it.
- It parses the whole stdout as JSON; if that fails, it uses the last non-empty line as the result.
- stderr is the log. Lines starting with
PROGRESS:are parsed as progress events and used for install progress display. - The exit code must be 0. If it isn't, the daemon treats the call as a generic error even if stdout contains
success: false. - The process exits after each call. The default time limit is 120 seconds; only
install_serverduring provisioning and updates is given longer (1800 seconds).
Unverified modules (Windows): Modules not downloaded from Saba-chan's signed catalog, or changed after download, are scanned with antivirus (AMSI / Defender) before running and run inside an isolated AppContainer. In that case they can't reach loopback (the daemon API, local game server REST), don't receive
SABA_TOKEN_PATH, and can only write to limited folders such as the instance folder. Keep this in mind when testing a module you built yourself.
4.1 Progress Reporting Example
# Inside install_server
import json, sys
def report(step, total, percent, msg):
sys.stderr.write("PROGRESS:" + json.dumps({
"step": step, "total": total, "percent": percent, "message": msg
}) + "\n")
sys.stderr.flush()
report(1, 3, 10, "Starting download")
# … actual download …
report(2, 3, 70, "Extracting")
# …
report(3, 3, 100, "Install complete")
Recognized fields are percent, message, step, total, and label.
5. Shared Utilities
The daemon places the shared files i18n.py and daemon_rcon.py in the modules folder and adds it to PYTHONPATH. Don't copy them into your module folder.
import os
from i18n import I18n
from daemon_rcon import daemon_stdin_command, wait_for_exit, force_kill
MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
i18n = I18n(MODULE_DIR) # MODULE_DIR/locales/<SABA_LANG>.json
msg = i18n.t("errors.failed_to_stop", error="…") # replaces {{error}} in the string
daemon_stdin_command(config["instance_id"], "save-all") # send one line to the server console
daemon_rcon.py keeps its name for historical reasons only; it never opens an RCON socket. Main helpers:
| Function | Purpose |
|---|---|
daemon_stdin_command(instance_id, line) | Send a command to the console of a daemon-managed server |
daemon_post(path, payload) | Call other daemon APIs (token added automatically) |
is_process_alive, wait_for_exit, force_kill | Per-PID check / wait / force kill |
config_value(config, key, default) | Find a setting value in config |
run_lifecycle(FUNCTIONS) | Entry point for python lifecycle.py <function> |
Enabled extensions can be imported, e.g. from extensions.steamcmd import SteamCMD or import extensions.ue4_ini. Disabled extensions are blocked by an import guard (ModuleNotFoundError), so import inside the function and answer extension_required on failure.
6. Reference: Built-in Modules
| Module | Default Port | Protocol | Required Extensions | Notes |
|---|---|---|---|---|
minecraft | 25565 | stdin (console) | None | download install, Java auto-detect/download, RCON forced off |
palworld | 8211 | REST API | steamcmd, ue4-ini | Game without a console. credential_map maps rest_password ↔ AdminPassword |
zomboid | 16261 | stdin (console) | steamcmd | stop_command = "quit", [backup] hook = true |
All are built around module.toml and lifecycle.py. When creating a new module, we recommend starting from __example or whichever of these three is closest.
7. Development Rules (Mandatory)
Module PRs must follow these rules.
- No dependency on daemon code. Use only the config, environment variables, and APIs the daemon provides.
- Every failure is JSON + exit code 0. Answer with
success: falseand a reason. - Logs go to stderr. stdout is for the result JSON. Print exception tracebacks to stderr too; don't swallow errors.
- Turn RCON off, and never put
"rcon"insupported. - Handle processes only by the
pidthe daemon gives you. Killing by executable name also ends other instances of the same game. - Build paths with
os.path.join; don't hardcode them. - Verify field-name mappings. Check which game setting key each
[[settings.fields]]name maps to, in bothconfigureandimport_settings. - Tests. Keep unit tests in
test_lifecycle.py, and pass the conformance kit (section 8 below).
8. Debugging Tips
- The result is read from the last line of stdout, so send debug output to stderr. Printing anything after the result JSON breaks parsing.
- You can run a function directly without the daemon. Add the Saba-chan repository's
modules/toPYTHONPATHsodaemon_rconcan be found.echo '{"server_executable": "C:/Games/MyGame/GameServer.exe", "port": 7777}' | python lifecycle.py get_launch_command - Check
module.tomlwith the same parser the daemon uses:saba-core module validate <module folder> - Conformance kit:
pytest conformance --module path/to/mygame --shared-libs path/to/saba-chan/modules(in thesaba-chan-modulesrepository) - If progress doesn't show up in the GUI, check that the stderr line starts with exactly
PROGRESS:and that the JSON after it is valid. - After editing a module, also apply the change under
modules\<name>\in the data folder so the daemon uses the latest code. The daemon caches the module list, so if you changedmodule.toml, have it reload withPOST /api/modules/refresh.