Skip to main content
Reference Last updated: 24 February 2026

Plugin API

Untether’s public plugin API is exported from:

Untether’s public plugin API is exported from:

untether.api

Anything not imported from untether.api should be considered internal and subject to change. The API version is tracked by TAKOPI_PLUGIN_API_VERSION.


Versioning

  • Current API version: TAKOPI_PLUGIN_API_VERSION = 1
  • Plugins should pin to a compatible Untether range, e.g.:
dependencies = ["untether>=0.14,<0.15"]

Exported symbols

Engine backends and runners

SymbolPurpose
EngineBackendDeclares an engine backend (id + runner builder)
EngineConfigDict-based engine config table
RunnerRunner protocol
BaseRunnerHelper base class with resume locking
JsonlSubprocessRunnerHelper for JSONL-streaming CLIs
EventFactoryHelper for building untether events

Transport backends

SymbolPurpose
TransportBackendTransport backend protocol
SetupIssueSetup issue for onboarding / validation
SetupResultSetup issues + config path
TransportTransport protocol (send/edit/delete)
PresenterRenders progress to RenderedMessage
RenderedMessageRendered text + transport metadata
SendOptionsReply/notify/replace flags
MessageRefTransport-specific message reference
TransportRuntimeTransport runtime facade (routers/projects hidden)
ResolvedMessageParsed prompt + resume/context resolution
ResolvedRunnerRunner selection result

Command backends

SymbolPurpose
CommandBackendSlash command plugin protocol
CommandContextContext passed to a command handler
CommandExecutorHelper to send messages or run engines
CommandResultSimple response payload for a command
RunRequestEngine run request used by commands
RunResultEngine run result (captured output)
RunMode"emit" (send) or "capture" (collect)

Core types and helpers

SymbolPurpose
EngineIdEngine id type alias
ResumeTokenResume token (engine + value)
StartedEvent / ActionEvent / CompletedEventCore event types
ActionAction metadata for ActionEvent
ActionState / ProgressState / ProgressTrackerProgress tracking helpers for presenters
RunContextProject/branch context
ConfigErrorConfiguration error type
DirectiveErrorError raised when parsing directives
RunnerUnavailableErrorRouter error when a runner is unavailable

Bridge helpers (for transport plugins)

SymbolPurpose
ExecBridgeConfigTransport + presenter config
IncomingMessageNormalized incoming message
RunningTask / RunningTasksPer-message run coordination
handle_message()Core message handler used by transports

Plugin utilities

SymbolPurpose
HOME_CONFIG_PATHCanonical config path (~/.untether/untether.toml)
RESERVED_COMMAND_IDSSet of reserved command IDs
read_configRead and parse TOML config file
write_configAtomically write config to TOML file
get_loggerGet a structured logger for a module
bind_run_contextBind contextual fields to all log entries
clear_contextClear bound log context
suppress_logsContext manager to suppress info-level logs
set_run_base_dirSet working directory context for path relativization
reset_run_base_dirReset working directory context
ThreadJobJob dataclass for ThreadScheduler
ThreadSchedulerPer-thread message serialization
get_commandGet command backend by ID
list_command_idsGet available command plugin IDs
list_backendsDiscover available engine backends
load_settingsLoad full UntetherSettings from config
install_issueCreate SetupIssue for missing dependency

Runner contract (engine plugins)

Runners emit events in a strict sequence (see tests/test_runner_contract.py):

  • Exactly one StartedEvent
  • Exactly one CompletedEvent
  • CompletedEvent is last
  • CompletedEvent.resume == StartedEvent.resume

Action events are optional. The minimal valid run is:

StartedEvent -> CompletedEvent

Resume tokens

Runners own the resume format:

  • format_resume(token) returns a command line users can paste
  • extract_resume(text) parses resume tokens from user text
  • is_resume_line(line) lets Untether strip resume lines before running

EngineBackend

EngineBackend(
    id: str,
    build_runner: Callable[[EngineConfig, Path], Runner],
    cli_cmd: str | None = None,
    install_cmd: str | None = None,
)
  • id must match the entrypoint name and the ID regex.
  • build_runner should raise ConfigError for invalid config.
  • cli_cmd is used to check whether the engine CLI is on PATH.
  • install_cmd is surfaced in onboarding output.

TransportBackend

class TransportBackend(Protocol):
    id: str
    description: str

    def check_setup(...) -> SetupResult: ...
    def interactive_setup(self, *, force: bool) -> bool: ...
    def lock_token(
        self, *, transport_config: dict[str, object], config_path: Path
    ) -> str | None: ...
    def build_and_run(
        self,
        *,
        transport_config: dict[str, object],
        config_path: Path,
        runtime: TransportRuntime,
        final_notify: bool,
        default_engine_override: str | None,
    ) -> None: ...

Transport backends are responsible for:

  • Validating config and onboarding users (check_setup, interactive_setup)
  • Providing a lock token so Untether can prevent parallel runs
  • Starting the transport loop in build_and_run

CommandBackend

class CommandBackend(Protocol):
    id: str
    description: str

    async def handle(self, ctx: CommandContext) -> CommandResult | None: ...

Command handlers receive a CommandContext with:

  • the raw command text and parsed args
  • the original message + reply metadata
  • config_path for the active untether.toml (when known)
  • plugin_config from [plugins.<id>] (dict, defaults to {})
  • runtime (engine/project resolution)
  • executor (send messages or run engines)

Use ctx.executor.run_one(...) or ctx.executor.run_many(...) to reuse Untether’s engine pipeline. Use mode="capture" to collect results and build a custom reply.

ctx.message and ctx.reply_to are MessageRef objects with:

  • channel_id (int | str, chat/channel id)
  • message_id (int | str, message id)
  • thread_id (int | str | None; set when the transport supports threads, like Telegram topics)
  • raw (transport-specific payload, may be None)

Example: key per-thread state by (ctx.message.channel_id, ctx.message.thread_id).


TransportRuntime helpers

TransportRuntime keeps transports away from internal router/project types. Key helpers:

  • resolve_message(text, reply_text)ResolvedMessage (prompt, resume token, context)
  • resolve_engine(engine_override, context)EngineId
  • resolve_runner(resume_token, engine_override)ResolvedRunner (runner + availability info)
  • resolve_run_cwd(context)Path | None (raises ConfigError for project/worktree issues)
  • format_context_line(context)str | None
  • available_engine_ids() / missing_engine_ids() / engine_ids / default_engine
  • project_aliases()
  • config_path (active config path when available)
  • plugin_config(plugin_id)dict from [plugins.<id>]

Bridge usage (transport plugins)

Most transports can delegate message handling to handle_message(). Use TransportRuntime to resolve messages and select a runner:

from untether.api import (
    ExecBridgeConfig,
    IncomingMessage,
    RunningTask,
    RunningTasks,
    TransportRuntime,
    handle_message,
)

async def on_message(...):
    resolved = runtime.resolve_message(text=text, reply_text=reply_text)
    entry = runtime.resolve_runner(
        resume_token=resolved.resume_token,
        engine_override=resolved.engine_override,
    )
    context_line = runtime.format_context_line(resolved.context)
    incoming = IncomingMessage(
        channel_id=...,
        message_id=...,
        text=...,
        reply_to=...,
        thread_id=...,
    )
    await handle_message(
        exec_cfg,
        runner=entry.runner,
        incoming=incoming,
        resume_token=resolved.resume_token,
        context=resolved.context,
        context_line=context_line,
        strip_resume_line=runtime.is_resume_line,
        running_tasks=running_tasks,
        on_thread_known=on_thread_known,
    )

handle_message() implements:

  • Progress updates and throttling
  • Resume handling
  • Cancellation propagation
  • Final rendering

This keeps transport backends thin and consistent with core behavior.

Was this helpful?

Related Articles