Extensions

Extensions are self-contained feature modules that plug into Wactorz at both the backend (Python) and frontend (TypeScript) layers. They follow a shared protocol so the core system never needs to know about individual extension names, routes, or config — there is no if extension == "tts" anywhere.

Overview

wactorz/
├── ext/                    # Backend extensions (Python)
   ├── __init__.py         #     discovery, setup_all(), collect_public_config()
   └── tts/                #     TTS  edge-tts voice synthesis

frontend/src/
├── ext/                    # Frontend extensions (TypeScript)  mirror backend
   └── tts/index.ts        #     register({ apiBase, available })
├── ui/dashboard/
   └── icons.ts            #     Icon registry  registerIcon(name, svg)
├── ui/CardDashboard.ts     #     View registry  registerView(key, icon, label, builder)
└── config/serverConfig.ts  #     Config-seeding registry  registerConfigEntry(key, extract)

At startup the monitor calls setup_all(app), which auto-discovers every module or package under wactorz/ext/ that exposes a setup(app) hook and wires it in. collect_public_config(app) gathers each extension's non-secret browser config and merges it into /api/config. Nothing in core references a specific extension.

An extension is opt-in and self-disabling: it reads its own environment and no-ops when unconfigured, so shipping one forces nothing on anyone.


Backend

A backend extension lives in wactorz/ext/<name>/. Only setup() is required; public_config and on_ready are optional (duck-typed at discovery):

# wactorz/ext/<name>/__init__.py

def setup(app: web.Application) -> None:
    """Register routes, create state, wire lifecycle hooks."""

def public_config(app: web.Application) -> dict:
    """Return a dict merged into the /api/config response, namespaced by ext."""

async def on_ready(app: web.Application) -> None:
    """Called after every extension's setup() has run — for cross-extension
       logic that needs another extension already wired."""

# Optional: declare ordering dependencies for on_ready().
__deps__ = ["other-ext"]  # this on_ready() runs after other-ext's

Protocol

Hook When
setup(app) (required) register routes + state
public_config(app) (optional) merged into /api/config at startup
on_ready(app) (optional) runs as on_startup, topo-sorted by __deps__

Requirements

Example: TTS

wactorz/ext/tts/
└── __init__.py     # setup() registers /api/tts + /api/tts/voices,
                    # public_config() reports availability,
                    # on_startup warms the voice list. edge-tts is imported
                    # inside the handlers so the ext is a no-op when it's absent.

Frontend

A frontend extension lives in src/ext/<name>/ and exports a register() function from its barrel file. It seeds itself from the config that public_config() merged into /api/config, then communicates with the rest of the app only through the typed event bus (src/events.tsemit/listen), never by calling other components directly.

// src/ext/<name>/index.ts

export interface MyConfig {
    apiBase: string;
    available: boolean;
}

export function register(config: MyConfig): void {
    if (!config.available) return;
    // Seed the singleton, then let it self-wire via the event bus.
    myManager.setApiBase(config.apiBase);
    void myManager.init();
}

Wiring in main.ts

register() is called once during startup, after /api/config is seeded:

import { register as registerMyExt } from "./ext/myname";

registerMyExt({ apiBase: _apiBase, available });

Config seeding

config/serverConfig.ts fetches /api/config once and seeds registered keys into safeStorage (paired with a __server baseline so a user's local edit survives until the server value itself changes). Core registers only its own keys; your barrel contributes the extension's at module load — before seedServerConfig() runs:

import { registerConfigEntry } from "../../config/serverConfig";

// Fields your backend public_config() returns, namespaced by ext name.
registerConfigEntry("wactorz-myext-flag", c =>
    (c.myext as Record<string, unknown> | undefined)?.flag as string | undefined,
);

Keys not registered are never stored, even if the server sends them.

Dashboard views and icons

An extension that adds a dashboard tab uses the two registries — core never imports extension views or icon names:

import { registerIcon } from "../../ui/dashboard/icons";

export function register(config: MyConfig): void {
    // 1. Register the icon BEFORE registerView() uses its name.
    registerIcon("hexagon", '<path d="M21 16V8a2 …"/>');

    // 2. Register the tab: nav button + lazy view builder.
    config.registerView("mytab", "hexagon", "My Tab", () => buildMyView(config.onRender));
}

registerView(key, icon, label, builder) is a CardDashboard method; main.ts passes it (plus an onRender re-render thunk) into register(). The builder is a () => HTMLElement thunk called lazily on navigation — keep it pure and build a fresh element tree per call.

Registry Module Purpose
registerIcon() ui/dashboard/icons.ts Add SVG icons by name
registerView() CardDashboard (method) Add a nav tab + view builder
registerConfigEntry() config/serverConfig.ts Seed /api/config fields

Event bus

If your extension emits or listens for new event types, add them to AppEventMap in src/events.ts so payloads stay typed. Other components react to those events — they never import your extension. (TTS, for example, emits tts-audio-start / tts-audio-end; the ambient-audio manager listens and ducks its volume, with no import between the two.)

Testing

What Where
Barrel (register() logic) src/__tests__/ext/<name>/index.test.ts
Manager / behaviour src/__tests__/ext/<name>/<name>.test.ts

New extensions ship with tests; coverage is gated (bun run coverage).


Optional infrastructure (compose overlay)

If an extension needs an external service (a database, triple store, extra broker, …), do not add it to the base compose.yaml. Doing so forces the dependency on every user and turns the shared compose file into a merge-conflict magnet. Instead, ship an additive compose.<ext>.yaml overlay and activate the extension through an environment variable:

# compose.<ext>.yaml — additive, opt-in
services:
  my-service:
    profiles: [full]
    image: 
    networks: [wactorz-net]
# Layer the overlay onto either base stack; both read .env, so set the ext's
# activation var (e.g. MY_EXT_URL=…) there.
docker compose -f compose.yaml     -f compose.<ext>.yaml --profile full up -d
docker compose -f compose.dev.yaml -f compose.<ext>.yaml --profile full up -d

Because the extension reads its URL from os.getenv(…) and no-ops when unset, the base stack stays lean and the infrastructure is purely opt-in — the same "self-disabling" contract as the code.


Checklist