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.
Mark a tool as gated
Section titled “Mark a tool as gated”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 HITLasync 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.
What a suspension looks like
Section titled “What a suspension looks like”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.
Deciding
Section titled “Deciding”From the CLI (sectr dev / cloud with --cloud):
sectr send --approve # resume the turn with "approved"sectr send --deny # resume with the rejectionFrom the dashboard — the session banner or the approvals inbox, one click each.
From your own UI — the public API endpoint the dashboard itself uses:
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.
What happens on resume
Section titled “What happens on resume”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.