Skip to main content
ctrlrun-openai-agents makes an approve decision stop the run with the SDK’s own ToolApprovalItem instead of ApprovalRequired being raised past the runner. The human answers with state.approve(item), where this SDK’s users already answer, and one core provider writes the grant. The binding across the interrupt is the SDK’s, keyed by call_id, so CTRLRun records who answered and cannot re-check what they answered about: that is attribution, in that word. You probably do not need this. A plain @function_tool body under @protect is already covered. Use it when the SDK’s approval interruption is where your humans answer. Prerequisites: pip install ctrlrun-openai-agents, openai-agents>=0.20,<1.0. The blocks are the adapter’s own example, run against a real SDK install in this repository’s CI.
1

Build the Control, and wrap the tool

protected_tool builds the function_tool with needs_approval= answered by the policy through ctrlrun.adapter.needs_approval, and with failure_error_function=None. That second part is not optional: the SDK’s default turns a tool’s exception into “An error occurred while running the tool. Please try again.” for the model, which is exactly the retry a refusal exists to prevent.
2

Run, answer, resume

gate.run and gate.run_sync are Runner.run with CTRLRun’s exceptions arriving as themselves: the SDK wraps a tool’s exception in UserError, and these walk the chain back. unwrap(error) does the same if you call Runner yourself.
3

Know what a rejection leaves behind

The SDK does not invoke a tool whose approval was refused, so no CTRLRun action is proposed: no APPROVAL_DENIED, no ACTION_DENIED, no receipt. The refusal is real and in the SDK’s run output; CTRLRun was never asked. Record it where you call state.reject(item) if you need it in the evidence log. The conformance kit reports denial as not applicable for the same reason.

Where the SDK shows through

  • The predicate and @protect can disagree. needs_approval sees the raw arguments, not the decorator’s defaults or a resource= declared only there. A wrong yes asks a human about something harmless; a wrong no means the interrupt finds no answer for a call nobody was asked about and refuses it with ApprovalNotAsked, nothing written, the request left pending for ctrlrun approve. In neither direction does an unapproved action execute.
  • One answer authorizes one request, for the action the tool gated. A refund’s yes does not authorize a bank.wire raised beside it, and always_approve=True is refused as an answer because it records a decision about the tool, not the call.
  • Observe mode: the predicate answers “no approval needed”, because the SDK would not invoke a declined tool and a human’s no would stop what observe mode promises to run.
  • Retries: measured on openai-agents 0.22.0 against a remote that commits and then drops the connection, with no effect-level guard, the model retried until the refund had landed three or four times in one run, five runs out of five. Declare an effect=.

If it didn’t work

  • ApprovalNotAsked: the tool was invoked without the SDK having asked; pass the same resource= to protected_tool, give the tool no defaulted parameters, and route every @protect(wait=True) on this Control through protected_tool.
  • The model retries after a refusal: failure_error_function was left at its default; use protected_tool.
  • except DuplicateEffect never fires: you called Runner.run; use gate.run or unwrap.

Next