Extensions

Extend Tau with plain Python — custom tools, slash commands, hooks, dialogs, and message rendering.

Extensions are Python modules that customize a Tau session: they add tools and slash commands, observe the agent event stream, and intercept tool calls, tool results, and user input. The design follows Pi’s extension system, adapted to Python.

Quick start

Create ~/.tau/extensions/greet.py:

from tau_agent.tools import AgentTool, AgentToolResult


async def run_greet(arguments, signal=None):
    return AgentToolResult(
        tool_call_id="",
        name="greet",
        ok=True,
        content=f"Hello, {arguments.get('who', 'world')}!",
    )


def setup(tau):
    tau.register_tool(
        AgentTool(
            name="greet",
            description="Greet someone.",
            input_schema={
                "type": "object",
                "properties": {"who": {"type": "string"}},
            },
            executor=run_greet,
            prompt_snippet="Greet someone by name.",
        )
    )

Start tau and the model can call greet. Every extension is a module defining setup(tau), which runs once at startup with the extension API.

Where extensions live

LocationLoaded
~/.tau/extensions/by default
<project>/.tau/extensions/only with --project-extensions
any file or directorywith tau -x PATH (repeatable)

Within a directory, *.py files are extensions, and a subdirectory containing extension.py is a package-style extension — its sibling modules are imported with relative imports (from . import helper). Names starting with _ are skipped.

Larger extensions that keep their code in a package (e.g. a src/ layout) can declare their entry files in pyproject.toml instead of placing extension.py at the directory root:

[tool.tau]
extensions = ["src/my_ext/extension.py"]

The manifest takes precedence over an extension.py in the same directory; each declared file loads as a package rooted at its parent directory, so sibling modules stay importable with relative imports. The extension is named after the entry’s parent directory (or after the file itself when it isn’t named extension.py).

One caveat: tau -x on an entry file loads it standalone — no package, so relative imports fail. Once an extension has sibling modules, always pass a directory: the package directory itself, or the repo root when a manifest declares the entry.

Extensions load project-first; on name conflicts (extension names, tool names, command names) the first registration wins. --no-extensions disables directory discovery entirely (explicit -x paths still load). /reload awaits session_shutdown(reason="reload") on the outgoing extension generation, clears its UI, re-imports every extension and re-runs setup, then awaits session_start(reason="reload") on the new generation. Use those lifecycle hooks to stop and restart background work and to remount UI.

Security. Extensions execute arbitrary Python inside your session. Project extensions are therefore off by default — enable them with --project-extensions only in repositories you trust.

The extension API

def setup(tau):
    # registration
    tau.register_tool(agent_tool)            # tau_agent.tools.AgentTool
    tau.register_command("name", handler, description="...")
    tau.add_prompt_guideline("Never commit directly to main")
    tau.on("event_name", handler)            # or @tau.on("event_name")

    # message rendering (register in setup; send once running)
    tau.register_message_renderer("my-ext:status", render_status)

    # actions — valid once the session is bound, not during setup
    tau.send_user_message("text", deliver_as="follow_up")  # or "steer"
    tau.send_custom_message("text", custom_type="my-ext:status", details={...})
    await tau.append_entry("my-ext:records", {"key": "value"})
    tau.notify("message", "info")            # "info" | "warning" | "error"

    # read-only context
    tau.context.cwd, tau.context.model, tau.context.provider_name
    tau.context.session_id, tau.context.system_prompt
    tau.context.is_running, tau.context.has_ui
    tau.context.transcript   # parent conversation, deep-copied AgentMessages

    # interactive UI dialogs (async; see "UI dialogs" below)
    await tau.context.ui.select("Title", ["a", "b"])   # -> str | None
    await tau.context.ui.confirm("Title", "message")   # -> bool
    await tau.context.ui.input("Title", "placeholder") # -> str | None
    tau.context.ui.notify("message", "info")           # same as tau.notify

setup must be a plain def (not async def). Event handlers may be sync or async. Action methods raise ExtensionError if called before the session is bound — register handlers in setup and act on events instead.

Tools

register_tool takes a plain tau_agent.tools.AgentTool: a name, a description, a hand-written JSON-schema input_schema, and an async executor (arguments, signal=None) -> AgentToolResult. Give the tool a prompt_snippet to list it in the system prompt’s “Available tools” section, and prompt_guidelines for usage guidance tied to the tool. Registering a tool with a built-in’s name (read, write, edit, bash) replaces the built-in.

A long-running tool can stream progress: an executor that additionally declares an on_update parameter receives a callback (message: str, data: dict | None = None); each call becomes a tool_execution_update event and drives the TUI’s live progress line. Executors without the parameter are unaffected.

By default the TUI shows an unrecognized tool call as name {arguments} (truncated). Give the tool a render_call(arguments) -> str | None — to render a friendly one-line invocation instead (Pi’s renderCall): for example a subagent tool showing its description argument rather than the raw JSON. Return None to fall back to the generic line. Renderer errors are swallowed (diagnosed once per tool) and never crash the UI.

While a tool is executing, the TUI animates its row: a braille spinner stands in for the line’s leading marker (/ ) and, after the first second, a live elapsed time is appended (… (1m 23s)). Keep render_call output to a single line starting with a marker like so the spinner has something to replace.

For behavioral guidance not tied to any tool, add_prompt_guideline(text) adds a line to the system prompt’s Guidelines section (de-duplicated at build time; /reload rebuilds the prompt when guidelines change).

Commands

register_command(name, handler, *, description, usage, aliases) adds a slash command. Handlers are sync, receive (args: str, context), and may return a str shown to the user. Built-in commands cannot be overridden. Extension commands appear in the TUI autocomplete automatically.

UI dialogs

tau.context.ui gives extensions host-provided interactive dialogs (Pi’s ctx.ui). All three dialog methods are async:

choice = await tau.context.ui.select("Deploy to", ["staging", "prod"])
ok     = await tau.context.ui.confirm("Deploy?", "This ships to production.")
name   = await tau.context.ui.input("Release name", "e.g. v1.2.0")

Without an interactive frontend (print mode, -p, tests) every dialog returns its cancel default immediately, so extensions can call them unconditionally. Check tau.context.ui.has_ui (or tau.context.has_ui) if you want to branch on whether a real UI is attached.

Driving a dialog from a slash command. Command handlers are synchronous, so they cannot await a dialog directly. Instead, spawn a task on the running event loop and return immediately:

import asyncio

def _handler(args, context):
    async def _menu():
        choice = await context.api.context.ui.select("Action", ["deploy", "cancel"])
        if choice and choice != "cancel":
            context.api.send_user_message(f"run {choice}")
    asyncio.get_running_loop().create_task(_menu())
    return None  # any returned text opens a modal the user must dismiss first

def setup(tau):
    tau.register_command("menu", _handler)

The task runs on the same event loop as the session, so awaiting the dialog there is safe. (A tool executor, which is already async, can await tau.context.ui... directly.)

Component widgets

This seam lets an extension mount its own Textual widgets into the TUI instead of publishing string data. It deliberately makes Textual part of the public extension contract (the “component” type is textual.widget.Widget): extensions build against the Textual version tau pins, and a Textual major bump is a coordinated break for core and extensions together. An extension that runs its own conversations (e.g. subagents) builds its own agents strip and in-place conversation view with this seam. Prefer strings/data (message renderers, tool renderers, string slot widgets) when they are enough — they work in every frontend, including print mode; reach for widgets when the extension needs live, interactive UI.

tau.context.ui.components (a ComponentBridge) hosts extension widgets. Always gate on supports_components first — it is False in print mode and on any host without this seam, where every call below is a safe no-op:

def setup(tau):
    components = tau.context.ui.components
    if not components.supports_components:
        return  # print mode / older host: stay widget-less but functional

    # A persistent widget above or below the prompt. The factory runs on the UI
    # thread and receives the live theme.
    def build_strip(theme):
        return MyStripWidget(theme)          # a textual.widget.Widget

    components.set_slot_widget("my-widget", build_strip, placement="below_prompt")
    # set_slot_widget("my-widget", None) removes it again.

    # For plain text you can skip the factory (and the Textual import) entirely
    # by passing a list of display lines — the host renders them as Rich markup:
    #   components.set_slot_widget("status", ["[b]ready[/b]", "2 tasks queued"])

    # A pre-dispatch key hook (ports Pi's onTerminalInput): it is consulted
    # before the host's app-level priority bindings AND before the focused
    # widget, so returning True for "escape" preempts the turn-cancel and
    # returning True for "down" preempts completion nav. It fires for EVERY
    # main-screen key regardless of which widget has focus (never while a
    # modal dialog/picker is on top), so it MUST self-gate — e.g. on the
    # prompt text — and return True only for keys it actually consumes.
    def on_key(event, prompt_text):
        if prompt_text == "" and event.key == "down":
            ...            # activate your widget
            return True     # consume the key
        return False        # let it through
    unsubscribe = components.register_key_interceptor(on_key)

The host is defensive: a factory that raises, a widget that crashes in render/on_mount, or a throwing interceptor is isolated (quarantined and diagnosed) so a broken component never takes the TUI down. All mounted widgets are force-cleared on session rebind (/resume, /new) and teardown; also clear your own on session_shutdown.

Events

Observation events mirror the agent event stream — subscribe by the event’s type literal: agent_start, agent_end, turn_start, turn_end, message_start, message_delta, thinking_delta, message_end, tool_execution_start, tool_execution_update, tool_execution_end, retry, queue_update, error — or agent_event for everything (fires per streamed token; prefer specific events). Handlers must be fast; they run on the session’s event loop. message_end carries provider token usage at event.message.usage (None when the provider reported none).

Lifecycle and intercepting hooks:

EventPayloadHandler may return
session_startSessionStartEvent(reason)
session_shutdownSessionShutdownEvent(reason)
inputInputEvent(text)InputHookResult(action, text, message)
tool_callToolCallHookEvent(tool_name, arguments)ToolCallHookResult(block, reason, arguments)
tool_resultToolResultHookEvent(tool_name, arguments, result)ToolResultHookResult(content, ok, details)

All other handler failures are contained: they are recorded as diagnostics (visible in /session) and never crash the session.

Messages and persistence

send_user_message delivers a user message into the conversation. During a run it queues as steering or a follow-up; when the session is idle the TUI starts a new turn with it — this is how background work reports back. append_entry(namespace, data) persists extension-owned data as a durable session entry replayed on resume.

Custom message rendering

To format an injected message instead of showing it as raw text, register a renderer in setup and send with send_custom_message:

from tau_coding.extensions import CustomMessageView, MessageRenderOptions

def render_status(view: CustomMessageView, options: MessageRenderOptions) -> str:
    icon = "[green]✓[/green]" if view.details and view.details.get("ok") else "[red]✗[/red]"
    line = f"{icon} [bold]{view.content}[/bold]"
    if options.expanded and view.details:
        line += f"\n[dim]{view.details}[/dim]"
    return line  # a Rich-markup string, never a widget

def setup(tau):
    tau.register_message_renderer("my-ext:status", render_status)

# once the session is running:
tau.send_custom_message(
    "build finished",
    custom_type="my-ext:status",
    details={"ok": True, "duration_ms": 1200},
)

Growing and maintaining an extension

Extensions have three natural sizes; each step is optional and none requires packaging:

  1. A single file (greet.py) — the quick start above. No config.
  2. A folder with extension.py — split helpers into sibling modules and import them relatively (from . import helper). No config.
  3. A repo with a src/ layout — declare the entry in pyproject.toml under [tool.tau] (see above). Tau reads only the [tool.tau] table; whether the repo is also an installable Python package is entirely your business (it helps IDEs resolve imports and lets tests import modules directly, but Tau never installs or pip-imports your extension).

Two rules keep all three shapes loadable:

Testing an extension. Load it through the real runtime rather than importing your modules directly — that exercises discovery, the synthetic package import, and setup registration exactly as a session does:

from tau_coding import TauResourcePaths
from tau_coding.extensions import ExtensionRuntime

def test_loads(tmp_path):
    paths = TauResourcePaths(
        root=tmp_path / "tau", cwd=tmp_path / "project",
        agents_root=tmp_path / "agents",
    )
    runtime = ExtensionRuntime()
    runtime.load(paths, extra_paths=(EXTENSION_DIR,), include_resource_dirs=False)
    assert runtime.extension_names == ("my_ext",)

extra_paths takes your extension directory (or repo root with a manifest); include_resource_dirs=False keeps the test hermetic — nothing from ~/.tau/extensions leaks in. To monkeypatch module globals in tests, patch the loaded synthetic module (find it in sys.modules by the tau_extension_ prefix), not your package’s import identity — the runtime only sees the former.

Example extensions

See examples/extensions/:

A larger, real-world extension lives in its own repository: rian-dolphin/tau-subagents ports pi-subagents — an agent tool that spawns autonomous subagents in-process with their own tools and system prompts, foreground and background modes, agent types defined in .tau/agents/*.md, get_subagent_result and steer_subagent tools, an /agents command, and a custom renderer for completion notifications. It is also the reference for the [tool.tau] manifest shape above (a src/ layout package that feature-detects newer API seams).

git clone git@github.com:rian-dolphin/tau-subagents.git
tau -x ./tau-subagents
# then: "Use a subagent to summarize this repository's architecture."

Not yet supported

Compared to Pi’s extension system, v1 does not yet include: package management (pi install-style), custom providers, extension-authored TUI widgets (custom message rendering via register_message_renderer is supported; the host-provided context.ui dialogs are supported), custom entry renderers (non-context cards), keyboard shortcuts, CLI flag registration, system-prompt replacement, context rewriting, or a project trust store. The architecture document (dev-notes/architecture/phase-21-extensions.md) tracks these.