HITL gating destructive tools by default, not opt-in
Most frameworks make human-in-the-loop something you wire in. By the time you remember to add it, the agent has already shipped a tweet. Tutti gates destructive operations by default — the wiring is the runtime.
Three weeks into running an agent against real systems, you have one of two stories.
Story A: the agent has been quietly useful. It's read tickets, summarised threads, drafted PR reviews, answered questions. Nothing has gone wrong because it never had the ability to make anything go wrong.
Story B: the agent has done something. It posted a tweet you didn't intend, or refunded a customer twice, or commented on the wrong PR, or DM'd the wrong person. You're explaining to someone what happened.
The boundary between A and B is the moment you give an agent its first destructive tool. Almost every framework treats this moment as your problem. They ship the destructive tools alongside the read tools, in the same package, with no signal to the runtime that one is different from the other. Whether you wire approval is on you.
Tutti treats it as the runtime's problem.
The destructive flag
Every `Tool` has an optional `destructive` field on the interface from `@tuttiai/types`:
export interface Tool<T = unknown> {
name: string
description: string
parameters: ZodType<T>
execute(input: T, context: ToolContext): Promise<ToolResult>
destructive?: boolean
}A voice author marks anything that has real-world side effects with `destructive: true`. Posting messages, deleting records, sending DMs, issuing refunds, voiding invoices, opening PRs. The Stripe voice marks 18 of its 27 tools destructive. The Slack voice marks every send / edit / delete tool destructive. The Discord voice does the same. The GitHub voice does the same.
This isn't optional metadata. The runtime reads it.
What the runtime does with it
When a HITL-enabled runtime encounters a tool call that's marked destructive, the agent loop pauses. The call goes into the `InterruptStore` and emits an `interrupt:created` event. An operator — via the `tutti-ai interrupts` TUI, an SSE stream, or a REST API call to `/interrupts` — sees the call, sees the arguments, sees the surrounding trace, and approves or denies.
Approve, the call executes. Deny, the agent loop resumes with a typed denial result. The agent sees `{ content: '...', is_error: true }` and decides what to do next. No mystery, no silent skip.
Why opt-in HITL fails
The standard pattern is: HITL is a config you can enable. It's documented. It's recommended. It's off by default.
By the time you remember to enable it, the agent has already shipped a tweet you didn't intend. The opt-in pattern fails because it relies on the human author to predict, ahead of time, which calls are destructive. The author will get it right for the obvious cases and wrong for the long tail. The runtime never gets it wrong because it doesn't have to predict — every voice author marked the destructive tools at write time, once, and the runtime applies that mark every time.
What "by default" looks like
When you scaffold a new project with `tutti-ai init`, HITL is on. The example score file uses voices with destructive tools, and the docs explain how to approve them. To turn HITL off, you set `approvals: 'auto'` in the score's `runtime` section — a deliberate decision you have to write down, in code, that gets PR-reviewed.
This is the same pattern as TypeScript's `strict` mode. The default is the safe one. Opting out leaves a trace.
Where this falls short
HITL isn't free. If your agent makes 200 calls per run and 50 are destructive, an operator approving each one is going to be miserable. We have an open issue on batched approvals (approve all calls of type X in this run) and policy-based auto-approval (auto-approve refunds under $5; HITL the rest). Neither is shipped yet. For now, HITL is best for the agents you'd be most embarrassed if they went rogue — the customer support bot, the moderation bot, the release engineer. The ones that sit between an LLM and reputational damage.