Skip to content

Approvals (human in the loop)

Some actions shouldn’t run unattended: issuing refunds, sending emails, calling paid APIs. Sectr makes those tools gated: the turn suspends at the call and a human decides, then the turn resumes with the decision.

Either mechanism (they union):

@tool(needs_approval=True) # framework-free (sectr.tools)
async def refund(order_id: str) -> str: ...
@function_tool(needs_approval=True) # openai-agents native HITL
async def refund(order_id: str) -> str: ...

The platform can also gate by deployment config — SECTR_APPROVAL_POLICY (a JSON list of tool names the sidecar injects) gates a tool the author forgot to mark, with no code change. See sectr.tools.

The gated call raises ApprovalRequired; the adapter emits ToolApprovalRequired as the final event. No turn-end sentinel is journaled — the turn is suspended, not over. The session reports awaiting_approval, the dashboard shows a banner, and the approval lands in the cross-app approvals inbox.

From the CLI (sectr dev / cloud with --cloud):

Terminal window
sectr send --approve # resume the turn with "approved"
sectr send --deny # resume with the rejection

From the dashboard — the session banner or the approvals inbox, one click each.

From your own UI — the public API endpoint the dashboard itself uses:

Terminal window
curl -X POST https://api.sectr.dev/sessions/$SESSION_ID/approval \
-H "Authorization: Bearer $SECTR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"approved": true}'

Full flow details, the 409 cases, and TypeScript snippets: approve or deny from your UI.

The decision is journaled (TOOL_APPROVAL_GRANTED/DENIED), and the platform spawns a fresh runner whose invocation reason is the decision. Your handler sees it as ctx.reason.kind == "approval_decision" with ctx.decision and ctx.pending_tool_call in hand — execute the pending tool (or record the denial) and continue:

if ctx.reason["kind"] == "approval_decision":
pending = ctx.pending_tool_call
result = (
await ctx.execute(pending["name"], call_id=pending["tool_call_id"], **pending["arguments"])
if ctx.decision["approved"]
else {"error": "denied"}
)
yield ToolResult(tool_call_id=pending["tool_call_id"], result=result)

(Real code: examples/raw/main.py — the framework-free resume path. The openai-agents adapter does the equivalent with the framework’s own items.)

The resumed turn runs exactly like any other: streamed, journaled, ending at the sentinel. One decision per resume — two gated calls pending in one turn fail loudly rather than strand an unanswered call.