Skip to main content
How-To Guides Last updated: 8 October 2026

Write a plugin

Nathan Schram
By Founder, Little Bear Apps

Untether supports entrypoint-based plugins for engines, transports, and commands.

Checklist

  1. Pick a plugin id (must match ^[a-z0-9_]{1,32}$).
  2. Add a Python entrypoint in your package’s pyproject.toml.
  3. Implement a backend object (BACKEND) with id == entrypoint name.
  4. Install your package and validate with untether plugins --load.

Entrypoint groups

Untether uses three entrypoint groups:

[project.entry-points."untether.engine_backends"]
myengine = "myengine.backend:BACKEND"

[project.entry-points."untether.transport_backends"]
mytransport = "mytransport.backend:BACKEND"

[project.entry-points."untether.command_backends"]
mycommand = "mycommand.backend:BACKEND"

Engine backend plugin

An engine backend builds a Runner via build_runner(...).

Minimal example:

# myengine/backend.py
from __future__ import annotations

from pathlib import Path

from untether.api import EngineBackend, EngineConfig, Runner


def build_runner(config: EngineConfig, config_path: Path) -> Runner:
    _ = config_path
    return MyEngineRunner(config)


BACKEND = EngineBackend(
    id="myengine",
    build_runner=build_runner,
    cli_cmd="myengine",
    install_cmd="pip install myengine",
)

Engine config is a raw table in untether.toml:

untether config

untether config set myengine.model "..."

toml

[myengine]
model = "..."

Transport backend plugin

Transport plugins connect Untether to other messaging systems (Slack, Discord, …). For most transports, delegate message handling to handle_message() from untether.api.

Command backend plugin

Command plugins add custom /command handlers. They only run when the message starts with /<id> and the id does not collide with engine ids, project aliases, or reserved names.

Minimal example:

# mycommand/backend.py
from __future__ import annotations

from untether.api import CommandContext, CommandResult


class MyCommand:
    id = "hello"
    description = "say hello"

    async def handle(self, ctx: CommandContext) -> CommandResult | None:
        _ = ctx
        return CommandResult(text="hello")


BACKEND = MyCommand()

To reply with a file instead of (or as well as) text, set attachment: CommandResult(text="report ready", attachment=CommandAttachment(filename="report.md", content=data)). Telegram sends it as a document with text as the caption, and falls back to a plain message if the upload fails or the file is over 10 MB. Attachments work for text commands only; callback results ignore them. See CommandAttachment.

Command plugin configuration

Configure under [plugins.<id>]:

untether config

untether config set plugins.hello.greeting "hello"

toml

[plugins.hello]
greeting = "hello"

The parsed dict is available as ctx.plugin_config in handle().

Enable/disable installed plugins

untether config

untether config set plugins.enabled '["untether-transport-slack", "untether-engine-acme"]'

toml

[plugins]
enabled = ["untether-transport-slack", "untether-engine-acme"]
  • enabled = [] (default) means “load all installed plugins”.
  • If non-empty, only distributions with matching names are visible.

Validate discovery and loading

untether plugins
untether plugins --load
Was this helpful?

Related Articles