0001 · Tier-2 approval through Strands hooks¶
- Status: accepted (first version, issue 004; refined by issue 006)
- Date: 2026-09-29
Context¶
Every tool has a tier (0 read, 1 digital write, 2 physical action or self-written code). Tier-2 calls need an explicit user decision through whichever channel the conversation came from (terminal, TUI, Telegram, TauBar). The same policy must also serve code that runs outside a Strands agent loop: the reflex engine (issue 037) and the tau MCP server (issue 028).
Strands Agents offers two ways to stop a tool call: a BeforeToolCallEvent hook that sets
cancel_tool (the model receives a tool result with status: "error" and our text), and the
interrupt API (event.interrupt(...)), which suspends the whole invocation until it is resumed
with a response.
Decision¶
TierGate(intau_core.approval) is the policy.check(tool, arguments, tool_use_id)returns(allowed, refusal_text, approval);record(...)emits atool_callagent event for every tier. It knows nothing about Strands and can be called by the reflex engine or the MCP server directly.TierHookis a thin Strands adapter: an asyncBeforeToolCallEventcallback asks the gate and setsevent.cancel_tool = refusal_textwhen the call is not allowed; anAfterToolCallEventcallback records status, result summary, approval and duration.Approveris a protocol (name,async decide(request) -> Decision) implemented per channel:TerminalApprovernow,TuiApprover(008), Telegram (017), TauBar (025).- The default approver when a channel supplies none is
AutoRejectApprover. There is no approver that approves everything, not even in tests; tests script decisions withScriptedApprover([Decision.APPROVED])and assert the recorded requests. - Fail closed: a tool name the catalog does not know (hallucinated, or registered with
Strands behind tau's back) is refused with
Tool '{tool}' is not registered; it was not run.and anerrorevent. A failing approver or exemption also rejects (full list below). - Refusals reach the model as the tool result:
The user did not approve this action: {tool}. Do not perform it; tell the user briefly. - Exemptions (
(tool, arguments) -> bool) let registered routines (037) skip per-call approval; the exempt call is still recorded withapproval: "exempt". - Approval prompts are serialized per gate, so parallel tool calls never ask at the same time.
- A per-turn loop guard uses Strands
Limits(turns=agent.max_turns); hitting it ends the turn with a notice in the UI language and aturn_limitevent.
Refinements (issue 006)¶
- Timeouts.
TierGate(timeout_seconds=...)wrapsapprover.decide()inasyncio.wait_for; when it runs out the question is cancelled and the decision isDecision.TIMEOUT, which refuses the call with the same refusal text (the approval event and thetool_callevent saytimeout). When the gate has no timeout, the approver's optionaltimeout_secondsattribute applies, so a channel states its natural wait (minutes for a Telegram button) withoutbuild_agentknowing about it; the gate's value wins when both are set. The default is no timeout: silence never approves, a timeout only ends the wait.TerminalApproverhas none becauseinput()blocks a worker thread that cannot be cancelled and would swallow the next line the user types. - Everything that fails closed: an unknown tool; a registered tool whose tier became invalid;
an approver that raises, is not awaitable or answers anything but a
Decisionvalue; a timeout; an exception inside the gate itself (TierHookthen cancels the call with the refusal text). An exemption that raises counts as "not exempt", so the approver is asked. - Exemptions are consulted only for tier-2 calls to known tools, must return
True(a merely truthy value does not exempt) and receive a copy of the arguments.TierGate.add_exemption()returns an undo function, the hook the routine registry (037) uses at runtime. Exempt calls emit noapprovalevent; theirtool_callevent saysapproval: "exempt". - What is approved is what runs. Approval requests, exemptions and events get deep copies of
the arguments, so nothing that only looks at a call can change the input the tool receives.
Strands runs the tool with the
tool_useleft by the lastBeforeToolCallEventcallback, soTierHookmust stay the last registered hook that could rewrite it (build_agentregisters it after the persona hook). - The terminal prompt shows the tool, the arguments as JSON with sorted keys, and the effect
on one line; control, C1 and bidi/format characters coming from the model are escaped so they
cannot rewrite or hide the prompt. Only
y/yes/e/evet(any case, surrounding spaces ignored) approves, whatever the UI language. - Serialization is one prompt at a time per gate, with the lock created per event loop so a gate outlives the loop it was first used in.
- Logging:
tool_callrecords log tier 0 atDEBUG, tiers 1 and 2 atINFO(arguments, status, result summary, approval), unknown tools atWARNING; approval decisions atINFO, timeouts atWARNING, approver failures atERROR. Thetau chatfile log keepsINFO. - Registration:
tau_toolandtier_ofrefuse booleans and values outside 0-2;ToolCatalog.extendregisters all tools or none. - Loop guard:
[agent].max_turnscounts model calls per user turn; the tools the last allowed call requested still run, an answer given on the last allowed call is not cut, and the next user turn starts with a fresh budget.
Why not Strands interrupts (yet)¶
Interrupts pause the invocation and need the caller to resume it with the answer, which fits
approvals that must survive a restart (a Telegram approval that arrives after the hub was
restarted). For the terminal and the TUI the user answers within the same process, and
cancel_tool keeps the loop simple and testable. Revisit when Telegram approvals (017) have to
outlive the process.
Consequences¶
- Channels only implement
Approver; the policy stays in one place. - Approval timeouts are enforced by the gate; a channel only states how long it is willing to
wait (
timeout_secondson its approver) and must keepdecide()cancellable. - Tool calls are observed authoritatively through
AfterToolCallEvent; the Strands stream has no tool-result event, so UIs readtool_callagent events instead.
Amended (2026-09-29, issues 062 and 064)¶
- The gate texts the model reads are fixed English:
refusal_textis "The user did not approve this action: {tool}. Do not perform it; tell the user briefly." andunknown_tool_textis "Tool '{tool}' is not registered; it was not run." The user-facing notices (turn limit, model error, max tokens, cancelled, throttled, overflow) come from thetau_core.i18nmessage table in the UI language (Config.ui.language). - "What is approved is what runs" no longer relies on registration order.
TierHookregisters both callbacks withorder=HookOrder.SDK_LAST, so thetool_usethe gate sees is the one Strands runs, whatever hooks a plugin orbuild_agent(hooks=...)adds. A hook that mutatestool_use["input"]at any lower order is observed by the approval request; a test pins this. The guarantee is exactly: noBeforeToolCallEventcallback runs after the gate. Strands'HookRegistryaccepts any float order, sobuild_agentchecks the registry after building theAgent(hooks_behind_gate) and raisesConfigErrorwhen a component registered aboveHookOrder.SDK_LAST(or at it, after the gate) - an extra component that outranks the gate fails closed at build time. A hook added to the SDK agent after build cannot be refused; for that case the gate'sAfterToolCallEventcallback compares what ran with what it decided (a refused call that ran, an approved call whose arguments changed) and records the call aserrorwith anerrorevent (where: tier_gate). The call itself cannot be undone by then, which is why the build-time check is the real guarantee. - Plugin-provided tools that are not in the
ToolCatalogstay refused (fail closed); a component that wants its tools to run registers them throughtau.tools.build_agent(hooks=..., plugins=...)passes extra components through to theAgent. - Tier and effect live in the tool spec (
ToolSpec["annotations"]["tau"]), set bytau_toolthrough the SDK's validatedtool_specsetter;tier_of/effect_ofread the annotation first and the older instance attributes second, so a@tau_toolmethod keeps its tier when Strands builds a new tool object for the bound method. The annotation is trusted only for tools registered in-process (tau.toolsentry points,@tau_tool), where tau wrote it. A spec that arrives from outside the process (an MCPToolProvider, issues 033/069) is plain data and must not declare its own tier: the hub stripsannotations["tau"]and assigns tiers from its own manifest/config before such a tool reaches theToolCatalog. - Physical tools never run concurrently: when any registered tool is tier 2,
build_agentuses the SDK'sSequentialToolExecutorinstead of the default concurrent one. - A Strands interrupt raised by any hook (
event.interrupt(...)) is refused inside the same turn:TauAgent.streamresumes with{"tau": "rejected"}for every pending interrupt, showsnotice.interrupt_refusedand emits anerrorevent, and repeats this for every interrupt of the turn (a hook that interrupts on each of several tool calls) up to[agent] max_turnsrefusals. The budget is shared: StrandsLimitsare per invocation and a resume is a new invocation, so each continuation gets the turns and tokens the turn has left. When the refusals do not clear it (a hook that re-interrupts under a new name, or no budget left) tau leaves the interrupt state itself (_InterruptState.deactivate(), the only SDK way out short of answering) and ends the turn withinterrupt(or the limit reason); the history then ends on the assistanttoolUsemessage and the next turn repairs it. A session restored in interrupt state (the process died between the SDK persisting the interrupt stop and tau refusing it) is cleared the same way at the start of its first turn, with the notice and anerrorevent, instead of failing every prompt with the SDK's "must resume from interrupt" error. So the agent (and the persisted session) never stays in interrupt state. Interrupt-based approvals are issue 068. - The last consequence above is outdated: tool calls are still observed authoritatively
through
AfterToolCallEventand UIs readtool_callagent events, but the claim that the Strands stream has no tool-result event is dropped (tool_stream_eventis mapped to aToolProgressturn event).