Example: Advanced Plugins - Lifecycle Hooks & Interactive Prompts
What "advanced" means here Tau's plugin API doesn't let a plugin render arbitrary custom widgets into the TUI -
there's no "draw this box" call. What it does give a plugin is real control over the agent's behavior (via lifecycle event hooks) and two interactive surfaces that appear in the TUI/Web UI on demand: host.Confirm() (yes/no) and host.Input() (free text). Combined, that's enough to build guardrails, approval flows, and context-aware plugins - this page shows how. :::
If you haven't read the Plugin SDK guide, start there for the Extension interface basics. This page builds a guardrail plugin that:
- Blocks a dangerous shell command unless the user explicitly confirms it.
- Injects an extra system-prompt instruction and a tracing header into every LLM call.
- Notifies the user when a new session starts.
Declaring the right capabilities
func (p *GuardrailPlugin) Capabilities() []string {
return []string{pluginapi.CapabilityEvents, pluginapi.CapabilityInteractive}
}This plugin has no slash commands or tools of its own - it only reacts to lifecycle events and prompts the user - so it omits CapabilityCommands and CapabilityTools. Tau skips calling RunCommand/Tools/ExecuteTool on it entirely, saving a gRPC round-trip per turn.
Routing events
DispatchEvent receives every event as a string plus a typed payload oneof. The pattern is a switch on event, using the matching payload getter:
func (p *GuardrailPlugin) DispatchEvent(
ctx context.Context, event string, sessionID string, payload *pluginapi.EventPayload,
) *pluginapi.EventResponse {
switch event {
case "session_start":
return p.onSessionStart(ctx, payload.GetSession())
case "before_llm_call":
return p.onBeforeLLMCall(payload.GetBeforeLlmCall())
case "before_tool_exec":
return p.onBeforeToolExec(ctx, payload.GetBeforeToolExec())
default:
return nil // no opinion on this event
}
}Returning nil is always safe - it tells tau "proceed as normal." You only need to return an *EventResponse for events you actually want to influence.
Blocking a tool call pending confirmation
before_tool_exec fires immediately before any tool executes - built-in, plugin-provided, or MCP-bridged. This is where you'd implement an approval gate:
func (p *GuardrailPlugin) onBeforeToolExec(ctx context.Context, call *pluginapi.ToolCallPayload) *pluginapi.EventResponse {
if call.ToolName != "shell_execute" {
return nil
}
var args struct {
Command string `json:"command"`
}
_ = json.Unmarshal([]byte(call.Arguments), &args)
if !looksDangerous(args.Command) {
return nil
}
// Suspend the turn on an interactive confirm - this renders as a
// yes/no prompt in the TUI (InteractivePromptRequestedEvent) and Web UI.
confirmed, err := p.host.Confirm(ctx,
"Run potentially destructive command?",
args.Command)
if err != nil || !confirmed {
return &pluginapi.EventResponse{
BlockToolExecution: true,
BlockReason: "blocked by guardrail plugin: user did not confirm",
}
}
return nil
}BlockToolExecution/BlockReason are only meaningful from a before_tool_exec handler - see Event → Response Field Compatibility for the full table of which EventResponse fields apply to which events.
Shaping every LLM call
before_llm_call is the highest-leverage event - it fires right before the HTTP request goes out, with the full message list, model ID, and headers available to inspect or rewrite:
func (p *GuardrailPlugin) onBeforeLLMCall(call *pluginapi.BeforeLLMCallPayload) *pluginapi.EventResponse {
return &pluginapi.EventResponse{
InjectSystemPrompt: "Never suggest destroying data without an explicit, separate confirmation step.",
AddHeaders: map[string]string{
"X-Guardrail-Session": p.sessionTraceID,
},
}
}InjectSystemPrompt is additive - it's appended to tau's existing system prompt, not a replacement. Combine it with InjectMessages / RemoveMessageIndices (valid on "context" and "before_llm_call") if a plugin needs to actively curate what the model sees, not just add to it.
Notifying without blocking
session_start is a good place for non-blocking, informational feedback - use host.Notify() rather than a confirm/input prompt so the session isn't held up:
func (p *GuardrailPlugin) onSessionStart(ctx context.Context, s *pluginapi.SessionEventPayload) *pluginapi.EventResponse {
if p.host != nil {
_ = p.host.Notify(ctx, "info", "guardrail plugin active for model "+s.ModelId)
}
return nil
}Notify() pushes a transient toast to both the TUI (via the notify queue) and the Web UI - it never blocks, unlike Confirm/Input.
Putting it together
type GuardrailPlugin struct {
host pluginapi.Host
sessionTraceID string
}
func (p *GuardrailPlugin) SetHost(h pluginapi.Host) { p.host = h }
func (p *GuardrailPlugin) Metadata() (string, []*pluginapi.Command) {
return "guardrail", nil // no slash commands
}
func (p *GuardrailPlugin) RunCommand(ctx context.Context, name, args string) (string, error) {
return "", fmt.Errorf("guardrail plugin: no commands")
}
func (p *GuardrailPlugin) Tools(ctx context.Context) ([]*pluginapi.ToolDefinition, error) {
return nil, nil // no tools
}
func (p *GuardrailPlugin) ExecuteTool(ctx context.Context, toolName, arguments string) (string, bool, error) {
return "", true, fmt.Errorf("guardrail plugin: no tools")
}
func (p *GuardrailPlugin) Reload(ctx context.Context) ([]*pluginapi.Diagnostic, []*pluginapi.Command, error) {
return nil, nil, nil
}Wire it up with the same plugin.Serve(...) boilerplate shown in the Quick Start - every plugin, simple or advanced, uses the identical main().
Where this can go
- Cost/rate guardrails: track token usage from
after_llm_call(AfterLLMCallPayload.Usage) and start rejecting turns viabefore_tool_execonce a budget is hit. - Compliance redaction: scan
context/before_llm_callmessages and returnRemoveMessageIndicesfor anything matching a secrets pattern before it reaches the model. - Human-in-the-loop tools: pair
host.Input()withbefore_tool_execto ask the user to fill in a missing parameter rather than blocking outright.
All of these are combinations of the same three primitives covered here: event hooks, EventResponse, and Confirm/Input/Notify. See the full event table for every event tau dispatches.