How to Gate Agent Actions With the OpenAI Decisions API
Your agent is about to refund an order, email the customer and close the ticket, and nobody has looked at any of it. The OpenAI Decisions API gives you a cheap checkpoint for that moment: one POST /v1/decisions request that picks the tool, says whether a human should approve the action, and scores the risk, each with numbers you can put thresholds on. By the end you will have a Python gate that runs the safe actions and sends everything else to a human approval queue.
The API has been in public beta since October 6, runs on gpt-6-luna only, and costs $0.10 per 1M input tokens with no charge for output.
What the Decisions API returns
You send one input and a list of questions. Each question is one of three types:
predicate: a yes/no condition, answered with aprobabilityfrom 0 to 1.choice: pick one of your options, answered withchoice,confidenceand aprobabilitiesarray.score: rate against ordered levels, answered with ascore,confidenceandprobabilities. The score is a probability-weighted average of zero-based level indexes, so it can land between levels.
Questions share the input but are answered independently, so a decision that depends on another answer needs a second request.
1. Define three questions for your OpenAI Decisions API agent gate
One choice question picks the tool, one predicate decides whether a human must approve, and one score rates the damage if the agent is wrong.
QUESTIONS = [
{
'type': 'choice',
'name': 'tool',
'instructions': 'Which tool does the proposed action need?',
'choices': [
{'value': 'lookup_order', 'description': 'Read-only order or shipping lookup'},
{'value': 'issue_refund', 'description': 'Refund or credit money to a customer'},
{'value': 'send_email', 'description': 'Send a message to a customer'},
{'value': 'other', 'description': 'Anything that fits none of the above'},
],
},
{
'type': 'predicate',
'name': 'needs_human',
'instructions': 'Does the action move money, change customer data, or contact a customer in a way that cannot be undone?',
},
{
'type': 'score',
'name': 'risk',
'instructions': 'How much damage could this action do if it is wrong?',
'levels': [
{'label': 'low', 'description': 'Read-only, or trivially reversible'},
{'label': 'medium', 'description': 'Reversible with some effort'},
{'label': 'high', 'description': 'Irreversible, or touches money or personal data'},
],
},
]
Names must be unique, because the response echoes them back. Keep the other fallback so unmatched inputs have somewhere to land. The gate below escalates on it.
2. Call the Decisions API from Python
OpenAI's guide lists Python SDK 3.26.0 or later for these examples.
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY
def ask(request_text: str, proposed_action: str) -> dict:
decision = client.decisions.create(
model='gpt-6-luna',
input='Customer request: ' + request_text + '\nProposed agent action: ' + proposed_action,
questions=QUESTIONS,
)
answers = decision.model_dump(exclude_none=True)['answers']
return {a['name']: a for a in answers}
The same request over plain HTTP is POST https://api.openai.com/v1/decisions with the same JSON body. A trimmed response looks like this (illustrative values; choice and score answers also carry a full probabilities array):
{
"answers": [
{ "type": "choice", "name": "tool", "choice": "issue_refund", "confidence": 0.91 },
{ "type": "predicate", "name": "needs_human", "probability": 0.97 },
{ "type": "score", "name": "risk", "score": 1.8, "confidence": 0.84 }
]
}
Predicate answers carry no confidence. Vercel's docs note that confidence and probabilities appear when the answering model returns them, so read them with .get().
3. Turn probabilities into a human approval gate
Auto-run strictly and escalate cheaply: the action runs only when the model is confident that needs_human is a no. Any missing field counts as a reason to ask a person.
MAX_NEEDS_HUMAN = 0.15 # auto-run only if the model is confident the answer is no
MAX_RISK = 0.6 # score runs from 0 (low) to 2 (high)
MIN_TOOL_CONFIDENCE = 0.7
def gate(answers: dict) -> tuple:
# Returns ('auto', tool_name) or ('human', reason).
if any(a['type'] == 'refusal' for a in answers.values()):
return 'human', 'a question was refused'
tool = answers.get('tool')
needs_human = answers.get('needs_human')
risk = answers.get('risk')
if not (tool and needs_human and risk):
return 'human', 'incomplete answers'
if needs_human.get('probability', 1.0) > MAX_NEEDS_HUMAN:
return 'human', 'needs_human p=%.2f' % needs_human.get('probability', 1.0)
if risk.get('score', 2) > MAX_RISK:
return 'human', 'risk score %.2f' % risk.get('score', 2)
if tool.get('choice') in (None, 'other') or tool.get('confidence', 0) < MIN_TOOL_CONFIDENCE:
return 'human', 'tool choice unclear'
return 'auto', tool['choice']
Treat these thresholds as starting points. Replay 100 past agent actions you have already judged by hand, then move the numbers until the human queue is a size your team can actually clear.
4. Fail closed on refusals and errors
A refusal is a successful answer with type: refusal and no probability, while the other questions are still answered. The gate sends it to a person. Wrap the call so API errors do the same, and log everything, because the response has no rationale.
import logging
import openai
log = logging.getLogger('agent.gate')
def route(request_text: str, proposed_action: str) -> tuple:
try:
answers = ask(request_text, proposed_action)
except openai.APIError as exc:
log.warning('decisions call failed: %s', type(exc).__name__)
return 'human', 'decisions call failed'
verdict = gate(answers)
log.info('gate input=%r answers=%s verdict=%s', proposed_action, answers, verdict)
return verdict
verdict, detail = route('Refund order 1042', 'issue_refund(order_id=1042, amount=180)')
if verdict == 'auto':
run_tool(detail) # your code: detail is the tool name
else:
approval_queue.put((proposed_action, detail)) # your code: show the reason to the reviewer
5. Send screenshots as inline base64
If the action involves an image, such as a photo of a damaged item, attach it as a data URL.
import base64
def screenshot_part(path: str) -> dict:
with open(path, 'rb') as f:
b64 = base64.b64encode(f.read()).decode()
return {'type': 'input_image', 'image_url': 'data:image/png;base64,' + b64}
decision = client.decisions.create(
model='gpt-6-luna',
input=[{'role': 'user', 'content': [
{'type': 'input_text', 'text': 'Proposed action: refund the order shown below.'},
screenshot_part('order.png'),
]}],
questions=QUESTIONS,
)
Gotchas
- Beta means moving parts. OpenAI expects general availability in the coming weeks and has not confirmed GA pricing. Pin the model name and rerun your labeled set when GA lands.
- No rationale. You get numbers, not reasons. The log from step 4 is your only explanation of why an action ran.
- Probabilities are signals, not truth. One forum tester reported that choice probabilities leaned toward the first option and shifted when the order changed. That is a community report, not an OpenAI benchmark, so test choice order on your own data. The gate leans on the predicate and score for the veto for that reason.
- Images are inline base64 only. Hosted URLs and
file_idinputs are rejected, and a request takes at most 128 images. - Not an authorization layer. Customer text lands in
input, so a crafted message can try to steer the answers. Keep hard permission checks and spend limits inside the tool code.
A gate that costs a fraction of a cent per action and fails closed lets you ship an agent with real tools while keeping a person on every action you cannot take back.