Extension authoring
Toposync extensions are installable Python packages that can add backend routes, services, pipeline operators, settings, frontend UI, static assets, and runtime data. The core discovers them through Python entry points, reads an extension.json manifest from package resources, and serves any bundled frontend assets from the installed wheel.
This page documents the current extension contract. It is already used by first-party extensions, but third-party extensions have not been validated as a public ecosystem yet. Treat external extension authoring as an intended direction that still needs real third-party package testing, sharper tooling, and compatibility hardening.
Current status
The extension runtime is real and powers the first-party product today:
toposync-ext-structural;toposync-ext-models;toposync-ext-images;toposync-ext-home-assistant;toposync-ext-cameras;toposync-ext-vision;- optional extensions such as streaming, AI, and spatial video.
The missing piece is ecosystem validation. Toposync does not yet have a proven set of independently maintained third-party extensions installed from outside this monorepo. Before considering the contract stable for a broader marketplace, we still need to validate packaging, upgrade behavior, frontend shared dependencies, version constraints, extension management UX, and security expectations with real external packages.
Runtime model
At startup, the backend:
- discovers Python entry points in the
toposync.extensionsgroup; - imports and instantiates each plugin;
- reads
extension.jsonthroughimportlib.resources; - rejects disabled, duplicate, or incompatible extensions;
- registers extension auth metadata;
- runs
setup(app, bus, services)for compatible extensions; - exposes public extension metadata from
GET /api/extensions; - serves bundled static files from
/extensions/<extension_id>/<path>.
The frontend host then calls GET /api/extensions, loads each Module Federation remote entry, gets the configured module, and calls activate(host).
Python wheel
-> entry point: toposync.extensions
-> plugin object
-> extension.json
-> optional FastAPI routes and pipeline operators
-> optional static/remoteEntry.js
-> frontend host loads activate(host)
Package shape
A typical extension should look like this:
toposync-ext-example/
pyproject.toml
README.md
LICENSE
src/
toposync_ext_example/
__init__.py
plugin.py
extension.json
static/
remoteEntry.js
...
data/
...
ui/
package.json
webpack.config.js
src/
activate.tsx
entry.ts
The Python package is the installable unit. The frontend bundle, manifests, templates, runtime data, and licenses must be included inside the wheel. Installed extensions must not depend on paths that only exist in the source checkout.
Python wheel metadata
Declare the package as a normal Python project and expose a toposync.extensions entry point.
[project]
name = "toposync-ext-example"
version = "0.1.0"
description = "Example Toposync extension."
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.11"
dependencies = [
"toposync-core>=0.3.0",
]
[project.entry-points."toposync.extensions"]
example = "toposync_ext_example.plugin:ExampleExtension"
[build-system]
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/toposync_ext_example"]
If the extension depends on another Toposync extension, declare that as a Python dependency and also declare runtime compatibility in extension.json.
Plugin class
Most extensions can inherit from BaseExtension.
from __future__ import annotations
from fastapi import FastAPI
from toposync.extensions import BaseExtension, register_extension_shutdown_callback
from toposync.runtime.event_bus import EventBus
from toposync.runtime.services import ServiceRegistry
class ExampleExtension(BaseExtension):
def __init__(self) -> None:
super().__init__(package="toposync_ext_example")
async def setup(self, app: FastAPI, *, bus: EventBus, services: ServiceRegistry) -> None:
@app.get("/api/example/ping")
async def ping() -> dict[str, str]:
return {"status": "ok"}
services.register("example.ping", lambda: {"status": "ok"})
register_extension_shutdown_callback(app, self.shutdown)
async def shutdown(self) -> None:
return None
def capabilities(self) -> dict[str, object]:
return {
"auth": {
"api_prefixes": ["/api/example"],
"action": "core:extension:use",
"resource_type": "core:extension",
}
}
Use setup() for runtime registration:
- FastAPI routes;
- event bus handlers;
- service registry entries;
- pipeline operators;
- background resources that need shutdown callbacks.
Use capabilities() when the core needs metadata about your extension. Today this is used for extension API authorization prefixes.
Manifest
Every extension must ship extension.json inside the Python package.
{
"schema_version": 1,
"id": "com.example.demo",
"name": "Example",
"version": "0.1.0",
"requires_core_version": ">=0.7,<0.8",
"requires_extensions": ["com.toposync.cameras>=0.1.0"],
"frontend": {
"kind": "module-federation",
"remote_entry": "remoteEntry.js",
"scope": "example",
"module": "./activate"
}
}
Fields:
schema_version: manifest schema version, currently1;id: stable extension id, preferably reverse-DNS style;name: user-facing name;version: extension runtime version;requires_core_version: optional PEP 440 specifier checked againsttoposync-core;requires_extensions: optional list of required extension ids, with optional version specifiers;frontend: optional Module Federation remote description.
Compatibility is enforced before setup(). If the core version or required extension versions do not match, the extension is skipped instead of partially loaded.
Static assets
If the package contains a static/ directory, the backend serves it at:
/extensions/<extension_id>/<path>
For a frontend extension, static/remoteEntry.js is the important file. Additional chunks, images, CSS, manifests, templates, model descriptors, and license files must also be included in the wheel if the extension needs them at runtime.
The asset route rejects absolute paths and .. traversal. Assets are protected by the same auth layer as the rest of the app.
Frontend remote
Frontend extensions use Module Federation. The remote must expose the module declared by frontend.module, usually ./activate.
import type { ToposyncHost } from "@toposync/plugin-api";
export function activate(host: ToposyncHost): void {
host.i18n.registerTranslations({
en: {
"example.settings.name": "Example",
},
"pt-BR": {
"example.settings.name": "Exemplo",
},
});
host.registerSettingsPanel({
id: "com.example.demo.settings",
name: { key: "example.settings.name", fallback: "Example" },
render: () => null,
});
}
The host passes a ToposyncHost from @toposync/plugin-api. Use import type whenever possible because @toposync/plugin-api is mostly a contract package.
For a third-party webpack build, share the host-level dependencies as singletons:
new container.ModuleFederationPlugin({
name: "example",
filename: "remoteEntry.js",
exposes: {
"./activate": "./src/activate.tsx",
},
shared: {
"@toposync/plugin-api": {
singleton: true,
requiredVersion: "^0.3.0",
},
react: {
singleton: true,
requiredVersion: false,
},
"react-dom": {
singleton: true,
requiredVersion: false,
},
three: {
singleton: true,
requiredVersion: false,
},
},
});
The exact shared dependency policy still needs third-party validation. First-party extensions use the monorepo helper createSharedDeps(), but external packages should not rely on that helper unless they intentionally build inside this repository.
Pipeline operators
Extensions can register domain-specific pipeline operators during backend setup.
from toposync.runtime.pipelines.operator_registry import OperatorRegistry
async def setup(self, app, *, bus, services) -> None:
registry = getattr(app.state, "pipeline_operator_registry", None)
if isinstance(registry, OperatorRegistry):
registry.register_operator(
operator_id="example.detect_condition",
description="Example condition detector.",
inputs=[{"name": "in", "required": True}],
outputs=[{"name": "out"}],
owner="com.example.demo",
share_strategy="by_signature",
)
For deeper pipeline runtime rules, see Pipelines.
Packaging outside the monorepo
The release artifact must work after a clean pip install, without the source tree.
Checklist:
- the Python wheel declares the
toposync.extensionsentry point; extension.jsonis inside the importable Python package;static/remoteEntry.jsand all JS chunks are inside the importable Python package;- runtime data files are included with the wheel;
- licenses for bundled code and assets are included;
requires_core_versionreflects the host versions you tested;requires_extensionsreflects runtime extension dependencies;- the frontend remote does not use absolute
/main.js-style asset paths; - the extension does not read from
extensions/<name>/...source checkout paths at runtime.
Build and inspect locally:
uv build
python -m zipfile -l dist/toposync_ext_example-0.1.0-py3-none-any.whl
Install into a clean environment:
uv venv /tmp/toposync-extension-test --python 3.12
source /tmp/toposync-extension-test/bin/activate
uv pip install toposync dist/toposync_ext_example-0.1.0-py3-none-any.whl
toposync serve
Then verify:
curl http://127.0.0.1:8000/api/extensions
The response should include your extension id and, if you ship frontend assets, a frontend.remote_entry_url.
Development workflow
Inside this repository, first-party extension development usually follows this loop:
uv sync
npm install
npm run build:extensions
TOPOSYNC_AUTH_MODE=bypass npm run dev
For an external extension repository, use a clean Toposync install plus an editable install of your package:
uv venv .venv --python 3.12
source .venv/bin/activate
uv pip install toposync
uv pip install -e .
npm install
npm run build
toposync serve
That external workflow is the one Toposync still needs to validate more thoroughly.
What not to do
Avoid these patterns:
- depending on source checkout paths;
- registering routes outside
/api/<extension-name>without a clear reason; - using frontend absolute paths that ignore Home Assistant ingress or reverse proxies;
- shipping model weights, binaries, or datasets without license review;
- adding vendor-specific behavior to the core instead of a separate extension;
- assuming private monorepo helpers are available to external packages;
- broad version requirements that allow untested future host versions.
Stabilization roadmap
Before Toposync can call third-party extensions fully supported, the next steps are:
- publish and version
@toposync/plugin-apias the public frontend contract; - provide an external extension template repository;
- add distribution tests that install a sample third-party wheel outside the monorepo;
- document supported webpack/Vite patterns for remotes;
- define a clearer compatibility policy for
requires_core_versionand plugin API versions; - improve extension management UX for installing, upgrading, disabling, and diagnosing external extensions;
- add security guidance for API routes, credentials, filesystem access, and bundled assets.