Extensions¶
Cognition × Application
Plain Python modules — importlib, no compile step, no manifest language. A
module exposes def register(api): ... and api is an ExtensionAPI.
Lives in tau_agent_core, so extensions work headlessly, under the TUI, and
over RPC without knowing which.
Two vocabularies, two handler contracts¶
api.on(name, handler) subscribes to both kinds, and routes by name. The name
you subscribe to therefore decides which contract you owe.
api.on, two contracts. Red marks the arrow that comes back, because that is the whole distinction: a mutating hook's return value is applied to the turn, so its handler takes (event, ctx) and gets a plain dict. A notify handler takes one argument and gets an AgentEvent object, and nothing it returns is read. Subscribing to a hook with a one-argument handler raises TypeError the first time it fires — which for tool_call is the fail-closed path, where a raising handler blocks the call.| Kind | Handler | event is |
|---|---|---|
| mutating hook, lifecycle hook | handler(event, ctx) |
a plain dict |
| notify-only event | handler(event) |
an AgentEvent object |
Mutating hooks — the return value is applied:
| Hook | What returning a value does |
|---|---|
tool_call |
veto the call, or patch its arguments, before execution |
tool_result |
modify content or details, or terminate, after execution |
before_agent_start |
contribute to the system prompt |
input |
transform the user prompt |
turn_end |
— |
user_turn_end |
— |
session_before_switch |
veto a session new, fork or switch operation |
Lifecycle hooks — session_start, session_shutdown. Each carries a
reason, for example "reload".
Notify-only events — agent_start, agent_end, turn_start,
message_start / message_update / message_end, tool_execution_start /
tool_execution_update / tool_execution_end, plus the wildcard "all".
Two sharp edges¶
Choose the turn-boundary hook by cadence. turn_end fires once per
agent-loop turn, so one user request resolved in six tool round-trips fires
it six times. user_turn_end fires once per prompt(). Anything that should
happen once per thing-the-user-asked-for wants the latter.
api.on("turn_end", ...) always resolves to the mutating hook, never the
notify event of the same name. There is no way to observe a plain
notify-grade turn_end through api.on — use "all" or
AgentSession.subscribe(). context was a hook in an earlier design and is
retired: calling api.on("context", ...) raises.
The ExtensionAPI surface¶
# Events — a mutating hook or a notify event through the same call.
# Returns an unsubscribe callable.
api.on(event: str, handler: Callable) -> Callable[[], None]
# Tools
api.register_tool(definition: dict | ExtensionToolDefinition) -> None
api.get_all_tools() -> list[Any]
api.set_active_tools(names: list[str]) -> None
# Commands and shortcuts
api.register_command(name: str, command: dict) -> None
api.register_shortcut(...) # guarded ctrl+e namespace
# Session state
api.append_entry(custom_type: str, data: dict) -> None # durable, not RAM-only
api.set_session_name(name: str) -> None
api.get_session_name() -> str | None
# Messaging
api.send_user_message(content: str, deliver_as: str = "followUp") -> None
api.send_message(message: dict, options: dict | None = None) -> None
# Turn origination — the one door, for extensions
await api.submit(...)
api.submit_threadsafe(...)
# Inter-extension pub/sub, on ext:<name>:<topic> channels
await api.emit(topic: str, payload: Any) -> None
# Per-extension config
api.config -> dict[str, Any]
api.ui -> ExtensionUI
api.context -> ExtensionContext
api.config is sourced from ~/.tau/config.json's extensions.<name> block,
keyed by file stem, and overridable per run with --ext-config
NAME.KEY=VALUE, where the CLI wins. It replaces register_flag and
get_flag, which were deleted rather than deprecated — they never
populated a value.
send_user_message's default is "followUp", not "steer". The other valid
values are "nextTurn" and "steer".
register_tool takes a plain dict¶
Not a ToolDefinition. tau_llm.define_tool() exists and is validated, but it
builds the other tool shape: its execute takes the tool's own parameters,
while an extension tool's execute is
execute(tool_call_id, params, signal, on_update, ctx) and receives the bound
ExtensionContext.
register_tool will not accept a ToolDefinition, and that is deliberate —
the two contracts are not interchangeable. See
tau_llm.
ExtensionAPI.register_tool() does default label to name, where
define_tool refuses to. That is a pi-compatible contract, not an
inconsistency to harmonise away.
Discovery and collisions¶
Discovery is the global directory ~/.tau/extensions/, loaded
alphabetically, then every explicit -e / --extension PATH, which is
repeatable and loads even under --no-extensions. --no-extensions disables
discovery only.
There is no project-local <cwd>/.tau/extensions/ discovery, and no
dependency-manifest convention inside an extension directory. The first is
deferred pending a trust gate rather than overlooked: a repository you cloned
should not be able to run code by being opened.
Collision handling differs by kind, on purpose:
| Kind | On a duplicate |
|---|---|
| tool | raises ValueError at load time |
| command | silent last-write-wins |
| shortcut | last-write-wins, with a logged warning |
Headless dialogs raise¶
In headless mode, ctx.ui.confirm, select, input and form raise
HeadlessDialogError unless a policy is configured — --ui-defaults
METHOD=ANSWER,... or config.json's ui_defaults. A dialog that quietly
picked a default would make an unattended run's result depend on a question
nobody saw.
The bus capability grant¶
An extension that touches a message bus declares TOUCHES_BUS = True and is
refused at load time unless the run opts in with --bus (CLI) or
bus_available=True (SDK). It is a capability grant, not a default.
The declaration has a second half: such an extension must also declare a
non-empty SUBJECTS, naming what it touches. Leaving it unset is refused even
with --bus given, because the grant is per-subject rather than blanket.
The shipped example is nats_bus.py, which needs
pip install 'ffwf-tau-agent-core[bus]' for its NATS client.