Skip to content

How to Develop Agent Extensions

An agent runtime is a Python project that runs as a local process under the Zelos Agent. Build one when you need to:

  • talk to devices or buses
  • stream telemetry into Zelos
  • expose runtime actions
  • package protocol or hardware integrations for reuse

For the concepts shared with tabs and panels, start at Develop Extensions.

Create a Project

zelos extensions create my-agent-extension --with agent

The name must be kebab-case: it starts with a lowercase letter, and single hyphens join words of lowercase letters and digits. The command creates a my-agent-extension/ directory and fails if it already exists.

--with is required. It names the parts, as a comma list of agent, tab and panel. --with agent is a Python runtime alone. For example, --with agent,panel adds a layout panel and the web project under web/ that builds it, and one extension.toml declares both. See Develop App Extensions for the tab and the panel.

Options:

zelos extensions create my-agent-extension \
  --with agent \
  --author "Jane Doe" \
  --email "[email protected]" \
  --github "janedoe" \
  --description "Decode and stream device telemetry" \
  --python-version 3.12 \
  --output ~/src
Option Effect
--with Required. The parts: agent, tab, panel, comma-separated.
--author Author name in extension.toml, pyproject.toml, and LICENSE. Default: your OS user name.
--email Author email in pyproject.toml. Default: <github>@users.noreply.github.com.
--github GitHub owner. The manifest repository becomes https://github.com/<github>/<name>. Default: a handle derived from the author name.
--description Project description. Default: empty.
--python-version Python version in major.minor form. Default: the template's, 3.11 today.
-o, --output Parent directory for the project. Default: the current directory.
--template-ref Template tag or branch. Default: the latest stable tag of zelos-extension-templates.
--no-setup Only render the files. Skip the steps below.

After it renders the project, the command:

  1. Runs uv sync to install dependencies.
  2. Runs git init and makes an initial commit.
  3. Installs the project into the local agent with install-local. It skips this step when --host points at another machine.

A failed step prints a warning and leaves the project in place.

The template ref comes from the GitHub API. If you hit its rate limit, set GITHUB_TOKEN or pass --template-ref.

In the Zelos App, Create Extension in the Extensions view does the same. Pick Agent, and add Tab or Panel if the extension needs them. The app installs the project right away, and the agent prepares its Python environment at install. It skips git init, so run git init yourself.

Project Layout

my-agent-extension/
├── extension.toml          # Manifest
├── pyproject.toml          # Python package and dependencies
├── main.py                 # Entry point
├── my_agent_extension/     # Your code: the name with hyphens as underscores
│   ├── __init__.py
│   └── extension.py        # Example SensorMonitor class
├── config.schema.json      # Configuration form schema
├── assets/icon.svg         # Marketplace icon
├── tests/test_extension.py # Example tests
├── Justfile                # install, run, check, test, package, release
├── README.md
├── CONTRIBUTING.md
├── LICENSE
├── .github/workflows/      # CI.yml and release.yml
├── .github/dependabot.yml
├── .pre-commit-config.yaml
└── .vscode/                # Editor settings and recommended extensions

The generated extension.toml:

name = "My Agent Extension"
version = "0.1.0"
author = "Jane Doe"
repository = "https://github.com/janedoe/my-agent-extension"
icon = "assets/icon.svg"
readme = "README.md"

[zelos]
version = ">=26.0.10"

[config]
schema = "config.schema.json"

[agent]
runtime = "python"
entry = "main.py"
python_version = "3.11"

[package]
paths = ["main.py", "my_agent_extension"]

The manifest name is the project name in title case, so the local ID is local.my-agent-extension. For every manifest field, see the Manifest Reference.

Write the Entry Point

The template's main.py shows the pattern to keep:

#!/usr/bin/env python3
import logging
import signal
from types import FrameType

import zelos_sdk
from zelos_sdk.extensions import load_config
from zelos_sdk.hooks.logging import TraceLoggingHandler

from my_agent_extension.extension import SensorMonitor

logger = logging.getLogger(__name__)


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)

    # Connects to the agent in ZELOS_AGENT_URL (default http://localhost:2300)
    zelos_sdk.init(name="my_agent_extension", actions=True)

    # Send Python log records into Zelos as well
    logging.getLogger().addHandler(TraceLoggingHandler("my_agent_extension_logger"))

    config = load_config()
    monitor = SensorMonitor(config)
    zelos_sdk.actions_registry.register(monitor)

    def shutdown_handler(signum: int, frame: FrameType | None) -> None:
        logger.info("Shutting down...")
        monitor.stop()  # ends the run() loop

    signal.signal(signal.SIGTERM, shutdown_handler)
    signal.signal(signal.SIGINT, shutdown_handler)

    monitor.start()
    monitor.run()

Keep these properties when you change it:

  • Start under if __name__ == "__main__":. Packaging and standalone action runs import the module to find actions. Code at import scope would start your extension during those imports. See Standalone Action Runs.
  • Handle SIGTERM. The agent sends it to stop the extension, then force-kills after grace_seconds. Make the main loop exit and clean up. See Stop and Shutdown.
  • Read configuration with load_config(). It applies schema defaults and validates. See Loading Configuration.
  • Write files to ZELOS_DATA_DIR. The code directory is read-only after a marketplace install.

Async Extensions

The synchronous pattern fits most extensions. When you use async libraries, such as an async HTTP or websocket client, run an asyncio loop and stop it from the signal handler:

#!/usr/bin/env python3
import asyncio
import signal

import zelos_sdk


class AsyncExtension:
    def __init__(self) -> None:
        self.source = zelos_sdk.TraceSource("data")
        self.source.add_event(
            "event",
            [zelos_sdk.TraceEventFieldMetadata("value", zelos_sdk.DataType.Float64)],
        )
        self._shutdown = asyncio.Event()

    def stop(self) -> None:
        self._shutdown.set()

    async def run(self) -> None:
        while not self._shutdown.is_set():
            self.source.event.log(value=42.0)
            try:
                await asyncio.wait_for(self._shutdown.wait(), timeout=1.0)
            except asyncio.TimeoutError:
                pass


async def main() -> None:
    extension = AsyncExtension()
    loop = asyncio.get_running_loop()
    for sig in (signal.SIGTERM, signal.SIGINT):
        loop.add_signal_handler(sig, extension.stop)
    await extension.run()


if __name__ == "__main__":
    zelos_sdk.init(name="async_extension")
    asyncio.run(main())

loop.add_signal_handler is not available on Windows. There, use signal.signal and call loop.call_soon_threadsafe(extension.stop) from the handler.

Manage Dependencies

Declare dependencies in pyproject.toml, then lock them:

uv add aiohttp   # adds the dependency and updates uv.lock
uv sync          # installs into the project's .venv

Commit both pyproject.toml and uv.lock. Packaging includes both, and the agent installs the exact locked versions with uv sync --frozen. See Python Environment.

Develop and Test Locally

The template's just recipes cover the local loop:

just install   # uv sync, plus pre-commit hooks
just run       # uv run python main.py
just check     # ruff format check and ruff lint
just format    # ruff format and autofix
just test      # pytest

just run runs the agent runtime against the agent at ZELOS_AGENT_URL, or localhost:2300. With no ZELOS_CONFIG_PATH, load_config() reads config.json in the project directory, so you can test specific values:

echo '{"sensor_name": "bench-01", "interval": 0.5}' > config.json
just run

The template's .gitignore already excludes config.json.

The example tests in tests/test_extension.py use the check fixture from the Zelos pytest plugin. See Testing.

Run Inside Zelos

Install the project into the local agent. create already did this unless you passed --no-setup. The agent reads your code from the project directory, so you do not reinstall after code edits:

zelos extensions install-local .
zelos extensions start local.my-agent-extension --config '{"sensor_name": "bench-01"}'
zelos extensions logs local.my-agent-extension
zelos live signals
zelos actions list
zelos extensions stop local.my-agent-extension
  • Pass configuration as --config '<json>' or --config-file config.json. The agent validates it against config.schema.json.
  • After a code change, stop and start the extension, or use Save & Restart in the app's Configure dialog.
  • After any change to extension.toml, such as a new panel, run zelos extensions install-local . again.
  • After a dependency change, rebuild the environment. Stop the extension first, because the agent refuses to reinstall a running extension:

    zelos extensions stop local.my-agent-extension
    zelos extensions reinstall local.my-agent-extension
    

    Reinstall in the app's extension details does the same. Reinstall keeps the configuration and data/.

  • After you add or change a standalone action, run zelos actions dump to refresh actions.json, then restart the agent or reinstall the extension. See actions.json.

Package and Release

just package                 # just build, then zelos extensions package .
just release 0.2.0           # bump, lock, format, check, test, build, commit, tag
git push --follow-tags       # the release workflow builds and publishes the archive

The generated extension.toml has a [package] section, which packaging requires. Preview the archive with zelos extensions package --dry-run .. Packaging also writes actions.json into the project; commit it or add it to .gitignore.

For archive rules, the release workflow, and marketplace submission, see Package and Publish Extensions.

Templates and Reference