Skip to content

How to Develop Agent Extensions

Agent extensions are Python projects that run as local processes under the Zelos Agent. Use 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 app extensions, start at Develop Extensions.

Create a Project

zelos extensions create my-agent-extension

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

Options:

zelos extensions create my-agent-extension \
  --author "Jane Doe" \
  --email "[email protected]" \
  --github "janedoe" \
  --description "Decode and stream device telemetry" \
  --python-version 3.12 \
  --output ~/src
Option Effect
--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 3.11.
-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 template, 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 Extension.

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, dev, 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"

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

[host]
type = "agent"

[host.agent]
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 --extra dev, plus pre-commit hooks
just dev       # uv run python main.py
just check     # ruff lint
just format    # ruff format and autofix
just test      # pytest

just dev runs the extension 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 dev

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. 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 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                 # zelos extensions package .
just release 0.2.0           # bump, format, lock, check, test, 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 --list .. 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