AI agent guardrails: check tool calls before dispatch
Add a policy decision before a Python tool executes. Trace proposals separately from execution, test Audit mode, and handle unavailable Guard decisions.
A model can propose a valid tool call that your application should not execute. The arguments can match the schema while selecting the wrong resource or contradicting the user's instruction. Put the relevant policy check at the point where your application still controls dispatch.
OpenAI's function-calling flow separates receiving a model's proposed call from executing application code. That gap is the useful enforcement boundary. A check after execution can record an incident, but it cannot prevent the action that already happened.
Keep proposals and execution separate
For an instrumented Python agent, inspect three records:
- The provider call that produced the proposed tool and its arguments.
- The policy decision made before dispatch, when configured.
- The tool function that actually executed, including its observed result or error.
Staso's @st.agent creates the supported run root. @st.tool records the Python function when it runs. Decorating a tool does not run Guard by itself.
The supported native provider integrations evaluate configured policies before returning tool proposals to the normal dispatch loop. This path covers non-streaming OpenAI Chat Completions, Anthropic Messages, and direct LiteLLM calls on the tested OpenAI route. For a direct Python call, place an explicit st.guard() check before the function call.
Start with a policy that can be reviewed
Open Rules and choose a check relevant to your tool. Create an Audit policy in Policies, attach the rule, and scope it to the intended agent, environment, and tool. Confirm the policy is enabled. An empty or unmatched policy cannot produce the finding you are testing.
| State | What happens to the current proposal |
|---|---|
| An Audit rule matches | A would-block finding is recorded; the action remains allowed |
| An Enforce blocking rule matches | The supported proposal is blocked before dispatch |
| A rule lacks evidence | It can skip or abstain without producing a finding |
| Evaluation is unavailable | The configured fail-open or fail-closed behavior determines whether the action continues |
Built-in rules are templates until attached to an enabled policy. Review false positives in Audit before using Enforce where your plan permits it. Current plan allowances and mode availability are listed on the pricing page.
Put a check before a direct Python call
This example uses an in-memory record. It makes a real Staso Guard request when run, but it does not call a model, delete data, or contact a customer system.
Install Staso in Python 3.11 or newer:
python3 -m pip install --upgrade stasoSet STASO_API_KEY and STASO_WORKSPACE_SLUG in your environment for the workspace you will inspect. The key needs traces:write and guard:evaluate. Configure the example policy for agent record-lookup, environment development, and tool lookup_record.
Save this as guarded_lookup.py:
import staso as st
st.init(agent_name="record-lookup", environment="development")
@st.tool
def lookup_record(record_id: str) -> dict[str, str]:
records = {"sample-1": {"status": "ready"}}
return records[record_id]
@st.agent
def run(record_id: str) -> dict[str, str]:
arguments = {"record_id": record_id}
decision = st.guard(
"lookup_record",
arguments,
fail_closed=True,
)
if not decision.decision_available:
raise RuntimeError(f"Guard unavailable: {decision.failure_kind}")
if decision.action == "block":
raise PermissionError(decision.reason)
return lookup_record(**arguments)
try:
print(run("sample-1"))
finally:
delivery = st.shutdown()
if not delivery.ok:
print("Trace delivery incomplete:", delivery.last_failure)Run python3 guarded_lookup.py. An allowed decision reaches the local lookup and prints its result. A policy block or unavailable evaluation raises before the lookup executes. This harmless request does not guarantee a policy match; a finding requires a rule whose condition matches the supplied evidence.
The example chooses fail_closed=True. Your application's recovery path should distinguish an unavailable evaluation from a policy rejection. The manual-check reference documents decision_available, failure_kind, and the effective action.
Verify the boundary with a trace
Open the new run in Traces > Traces in the selected workspace. Inspect the decision and whether the decorated lookup function executed. A proposed tool call alone does not prove execution.
st.shutdown() attempts to drain the SDK queue and returns a delivery report. Accepted delivery is not the same milestone as a queryable trace. Verify that the workspace actually returns the run before treating setup as complete.
For provider-proposed tools, handle st.GuardBlocked around the supported provider call so a rejected proposal does not reach your dispatch loop. The OpenAI, Anthropic, and LiteLLM guides show their respective paths. Streaming and LiteLLM Proxy traffic are outside this supported configuration.
For a complete proposal-to-execution example, run the LiteLLM tool-calling demo. It connects two model requests through one local Python lookup and explains which trace record proves execution.
Give a rule the evidence it needs
A rule cannot inspect data that was never supplied. Capture controls can suppress arguments, tool schemas, and history before a Guard request is sent. History is bounded and may be incomplete; the current tool's output is unavailable before execution.
Keep missing evidence separate from a violation. Review the rules catalog before selecting a check. For coding agents, Claude Code hook timing and Codex hook trust introduce additional boundaries before a check can run.
Get the next post in your inbox.
One email when we publish a new guide to agent tracing, hooks, or tool-call guardrails.