> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ctrlrun.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Use the OpenAI Agents SDK adapter

> Route an approve decision through the OpenAI Agents SDK's tool-approval interruption with ctrlrun-openai-agents.

`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.

<Steps>
  <Step title="Build the Control, and wrap the tool">
    ```python theme={null}
    from ctrlrun import Control, InterruptApprovalProvider, protect
    import ctrlrun_openai_agents as gate
    from ctrlrun_openai_agents import AgentsInterrupt, protected_tool

    control = Control(
        policy, store,
        approvals=InterruptApprovalProvider(store, AgentsInterrupt()),
        identity=..., authority=...,
    )


    @protect("stripe.refund", effect="refund:{payment_id}", wait=True, control=control)
    def issue_refund(payment_id: str, amount: int) -> str:
        return stripe.Refund.create(payment_intent=payment_id, amount=amount)


    async def refund_tool(payment_id: str, amount: int) -> str:
        """Issue a refund for a payment. Amounts are in integer minor units."""
        return issue_refund(payment_id=payment_id, amount=amount)


    agent = Agent(name="refunds", tools=[protected_tool(control, "stripe.refund", refund_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.
  </Step>

  <Step title="Run, answer, resume">
    ```python theme={null}
    result = await gate.run(agent, "refund txn_1")
    if result.interruptions:
        state = result.to_state()
        for item in result.interruptions:
            state.approve(item)          # or state.reject(item)
        result = await gate.run(agent, state)
    ```

    `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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## 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

* [Use the LangGraph adapter](/guides/langgraph-adapter): the prevention shape.
* [Three ways in](/get-started/three-ways-in).
* [The adapter's README](https://github.com/CTRLRun/ctrlrun/blob/main/adapters/openai-agents/README.md) · [Get started](/get-started/quickstart) · [Why](/why).


## Related topics

- [OpenAI Agents SDK tool approval](/cookbook/openai-agents-tool-approval.md)
- [Use the LangGraph adapter](/guides/langgraph-adapter.md)
- [Three ways in](/get-started/three-ways-in.md)
- [CTRLRun and framework human-in-the-loop](/compare/framework-hitl.md)
- [Adapters](/adapters.md)
