← All posts
#authorization#ai-agents#openai-agents-sdk

An Optional Guardrail Isn't a Guardrail

Mizara Engineering·July 31, 2026·6 min read

The first version of our OpenAI Agents SDK example did the obvious thing: exposed mizara_authorize as a tool, and told the agent, in the system prompt, to call it before doing anything consequential. It worked, in the sense that a well-behaved model reliably called it before sending a broadcast. Then we asked the question we'd been putting off: what happens when it doesn't?

Nothing happens. That's the whole problem. There's no error, no exception, no gate. The model just... proceeds to the next tool call, because from its perspective mizara_authorize was never anything more than a tool it had access to, same as any other. Calling it was a suggestion in the instructions, not a requirement the framework enforces.

why "call this tool first" doesn't actually stop anything

The easiest mistake to make here is thinking that exposing an authorization tool and telling the model to call it first counts as enforcement. It doesn't. That's not enforcement, that's cooperation - the model has to agree to do the right thing, every time, for it to work. We hit basically this same distinction the first time we wrote about this: a system prompt is a suggestion, not a control. Wiring a check in as another tool the model is instructed to call just moves that same suggestion one layer down and dresses it up as a mechanism.

The instructions say "you MUST call mizara_authorize first." The model, on a good day, does. On a bad day - a longer conversation, a slightly unusual phrasing, a model swap, a prompt that's been edited six times since anyone reread it end to end - it doesn't, and there's nothing downstream that would have caught it either way. The check and the action it's supposed to gate are two independent tool calls the model is free to sequence however it wants.

That's not a model-quality problem you fix by upgrading models or improving the prompt. It's an architecture problem: the check was never actually in the path of the thing it was checking.

what actually runs before the tool

OpenAI's Agents SDK has a real answer to this - tool_input_guardrails - and we'd been building an example that didn't use it. A guardrail attaches to the tool itself, not to the model's discretion, and runs before the tool's execute function ever does. If it rejects, the tool doesn't run. The model doesn't get a vote.

Before:

@function_tool
def mizara_authorize(actor_id, action_name, resource_type, resource_id):
    """Call this before executing any consequential action."""
    ...
 
agent = Agent(
    name="comms-agent",
    instructions=(
        "Before sending any broadcast, you MUST call mizara_authorize first. "
        "If it returns DENY, explain the policy limit and do not proceed."
    ),
    tools=[mizara_authorize, send_customer_broadcast],
)

After:

@function_tool(tool_input_guardrails=[mizara_guardrail(mizara)])
def send_customer_broadcast(message: str, recipient_count: int, external: bool) -> str:
    ...
 
agent = Agent(
    name="comms-agent",
    instructions=(
        "You are a customer communications agent. Send broadcasts as requested "
        "using send_customer_broadcast."
    ),
    tools=[send_customer_broadcast],
)

Worth noticing what's missing from the second version: the instructions don't mention authorization at all. The model doesn't need to know a policy check exists, because it isn't the one enforcing it. That's the actual test of whether a check is a guardrail or a suggestion - does removing every mention of it from the prompt change whether it still runs.

what this looks like when it runs

Two real runs against the same agent, same policy, nothing scripted:

Send a broadcast to our 200 internal team members announcing the new release.
→ Broadcast sent to 200 internal team members.

Send a broadcast to all 50,000 external customers announcing the new release.
→ I couldn't send that broadcast because it was blocked by policy.

The second call never reached send_customer_broadcast - the guardrail rejected it before the tool body ran, and the rejection message came back to the model as the tool's result, which is why it can explain the block instead of either crashing or lying about having sent it. Every decision, allowed or blocked, still gets the same signed receipt as any other authorize() call - the guardrail is a wrapper around the same evaluation, not a separate code path.

this isn't an OpenAI problem

We're not convinced this is specific to any one framework - it's what we'd expect anywhere a model decides which tools to call. LangChain's create_agent has the same shape under a different name: middleware wrapping wrap_tool_call, rejecting before the underlying tool ever runs, same as above. The pattern to look for is the same regardless of which framework you're in - a hook that runs in the execution path before a tool fires, independent of anything the model decided. If the only thing standing between a model and a consequential action is another tool call it has to remember to make, that's true whether the framework is OpenAI's, LangChain's, or one nobody's built yet.

the lesson

A tool-calling framework can tell you whether an action is possible to execute. It can't tell you whether it should happen. If that second question gets answered by the model - even indirectly, by trusting it to call the right tool at the right time - the answer is living in the prompt, not in the runtime. And the prompt is the easiest layer in the whole system to have quietly stop working.

Moving the check into a tool_input_guardrails entry doesn't change what's being decided. It changes who decides: the framework, deterministically, instead of the model, statistically. The model doesn't get a vote - not on whether the action happens, and not on whether the check that gates it runs at all. That second part is the one it's easy to get wrong, because a tool the model is instructed to call looks, from the outside, exactly like enforcement right up until the one time it isn't.

That's the same line we landed on the first time: whether an action should happen is a different question from whether it's allowed to run. This post is what answering that question actually looks like in code.


A framework can tell you whether an action can run. Mizara answers the other question: whether it should.

Post fingerprint

{
  "id": "rcpt_blog_0da2d9f59b023a38",
  "sha256": "0da2d9f59b023a38...",
  "published": "2026-07-31"
}

Every authorize() call gets a hashed, verifiable receipt. This post gets the same treatment — the hash above is computed from the exact source of this article. Fetch the live hash and confirm it matches.