OpenAI Agents
Protect an OpenAI Agents SDK run at every turn with OGuardAI, so tool results, model calls, traces, and the session see only tokens
The OGuardAI Python SDK ships an optional OpenAI Agents SDK integration that protects a run at three
seams, so raw PII never reaches the model or its function and MCP tools at any turn, not just at the
outer boundary; their spans and the session carry only tokens too. A few tool kinds the SDK traces
itself (shell, computer, guardrail replacements) leave a documented trace-only residual, listed below.
It wraps the run, it does not replace it: GuardedRunner installs onto your existing agent and drives
the normal Runner.
The three seams
- S1, tool results.
install(agent)replaces the invoker of every reachable function tool (acrossagent.tools, handoffs, and the MCP tools fetched per run) with a guard that protects whatever the tool returns before it becomes what the model, the trace, the session, and the run result see. A tool that returns a raw customer record mid-run is tokenized at that boundary. - S2, the model boundary.
run_config(cfg)chains a model-input filter after your own filter, so before every model call every scannable string in the input is tokenized in one structured transform on the shared session. This is the backstop that catches raw introduced by a tool, a handoff, a filter, or pre-existing session history. - S3, the outer boundary.
run/run_synctransform the user input before the run and rehydrate the final output after it, outside the run, so no tracer ever sees a restored value.
One sealed session is chained across all three seams, so a value tokenized at one seam keeps the same token id at the next (a name from the user input keeps its id when a tool returns it and when a handoff target reads it).
Protection is default-on and fail-closed: a seam that fires with no active run scope raises and aborts, a transform that returns no session state latches the run and aborts before the next model call regardless of a tool's own error policy, and unscannable content (an image, a screenshot, a base64 file) is refused unless its kind is explicitly allow-listed.
Install
The integration is an optional extra, so openai-agents is only pulled in when you use it. Importing
guardai_sdk never requires it: the framework is imported lazily.
pip install "oguardai-sdk[openai-agents]"Python
from agents import Agent, function_tool
from guardai_sdk import OGuardAIClient
from guardai_sdk.integrations.openai_agents import GuardedRunner
client = OGuardAIClient(base_url="http://localhost:3000")
@function_tool
def lookup_account(account_id: str) -> dict:
# Returns a raw customer record MID-RUN. S1 tokenizes it before the model, trace, or session see it.
return {"name": "Max Mustermann", "email": "max.mustermann@example.de"}
agent = Agent(
name="Account Support",
instructions=(
"Look the customer up with the tool, then draft a short formal German reply. Reuse every "
"{{type:id:cap}} token EXACTLY as given and never invent a name, email, or number."
),
tools=[lookup_account],
)
runner = GuardedRunner(client, policy="german-support", language="de")
runner.install(agent) # S1: guard every tool result
result = runner.run_sync(agent, "Please send the invoice copy for account ACC-4711.")
print(result.output) # the reply, restored OUTSIDE the run
print(result.session_state) # the sealed session the run built
print(result.run_result) # the underlying Agents SDK RunResultrun is the async form; both open the run scope, transform the input (S3), attach the model-boundary
filter (S2), drive Runner, and rehydrate the final output (S3). Both return a GuardedRunResult
with output (the rehydrated final output), session_state (the sealed session), and run_result
(the raw RunResult).
The one-shot sugar installs, runs synchronously, and returns just the restored output:
from guardai_sdk.integrations.openai_agents import guarded_run
reply = guarded_run(agent, client, "Please send the invoice copy for account ACC-4711.",
policy="german-support", language="de")Public API
GuardedRunner(
client,
policy=None,
language=None,
output_channel="user_output",
restore_mode="full",
unscannable="refuse", # or a tuple of kind names to allow, e.g. ("input_image",)
)-
install(agent) -> agentwraps every reachable tool result. Idempotent and recursive acrossagent.tools, handoffs, and MCP servers; returns the agent for chaining. -
run_config(cfg=None) -> RunConfigreturns aRunConfigwhosecall_model_input_filterruns your own filter first and ours last.run/run_synccall it for you; call it yourself only when you driveRunnerdirectly. -
run_sync(agent, input, **kwargs)/run(agent, input, **kwargs)run the agent guarded and return aGuardedRunResult. Extra keyword arguments (context,max_turns,hooks,session, and so on) pass through toRunner. -
scope()is a context manager for callers who driveRunnerthemselves. Open it, transform the input, run withrun_config, then rehydrate:with runner.scope(): # transform your input, then: result = Runner.run_sync(agent, safe_input, run_config=runner.run_config(cfg)) # rehydrate result.final_output yourself, outside the run -
GuardedRunner.current().rehydrate(value, output_channel=...)restores one token-bearing value inside a tool that must act on a real value (for example send to a verified address). It is an explicit, per-channel de-tokenization; it fails closed when there is no active scope or session.
Unscannable content
Some parts of a run carry bytes the runtime never gets to inspect: an input_image, a
computer_screenshot, an image_generation_call result, an input_file referenced by inline data, a
URL, or an id, and the ToolOutputImage / ToolOutputFileContent tool results (as objects or in
their dict form). They can neither be tokenized nor proven free of raw values, so GuardedRunner
refuses them by default and fails closed. Letting one through is a conscious tradeoff you opt into by
naming its kind:
# Allow input images through unscanned; everything else is still refused.
runner = GuardedRunner(client, policy="p", unscannable=("input_image", "tool_output_image"))The kind names are input_image, computer_screenshot, image_generation_call, input_file,
tool_output_image, and tool_output_file.
What the model, tracer, and session see
The model is protected at every turn: S2 tokenizes the whole model input before each call, so no model
turn ever sees a raw value, including the tool results and handoff context a prior turn produced. A
value tokenized once keeps its token id across tool calls and handoffs, and the restored final output
exists only after the run, in GuardedRunResult.output.
Function-tool and MCP-tool spans carry only tokens: S1 protects the tool result before it becomes the span output, and if that protection fails the adapter blanks the span before it aborts, so a failed guard never emits a raw span. A few spans the SDK writes itself, before any seam can run (a shell or computer tool, a tool output guardrail replacement), are residual trace-only limits the adapter cannot rewrite even though the model is still protected by S2; see below.
What remains outside the seams
This adapter covers the local run: function tools, model calls, and the outer boundary. Some data the Agents SDK handles server-side or out of band is not something the adapter can tokenize. Guard these at their source:
- Hosted tools (web search, file search, hosted MCP, code interpreter): their output is produced and consumed server-side, so it never passes through the local tool or model-input seam.
- Server-managed conversation state (
conversation_id,previous_response_id): the model reads prior turns from the provider, not from the local input the S2 filter sees. - Local shell and computer tool spans:
ShellTool,LocalShellTool, andComputerToolare not function tools, so S1 does not wrap them, and the SDK records their raw stdout or screenshot in the tool's own span. S2 still tokenizes that output before it reaches the next model call, so the model is protected, but the tool's own span is a residual trace-only limit. Computer and shell screenshots are image bytes, so when they enter the model input they are refused as unscannable unless allow-listed. - Tool output guardrail replacements: a tool output guardrail runs after S1, so a
reject_contentreplacement it returns is not re-tokenized by the adapter. S2 tokenizes it before the model sees it, but the replacement can appear raw in the tool's span and in the stored run item, so do not put raw PII in a guardrail replacement. agent.as_toolnested agents: a nested agent run has its own tools. Callinstallon each agent whose tools you want guarded.- Exception text and custom spans: an exception whose message propagates as an error (rather than
being returned as a tool error string, which S1 tokenizes) and any content a tool writes into its
own
custom_spanare not tokenized by this adapter. - Pre-existing raw session history at rest: S2 tokenizes session history on the way to the model,
so the model and trace see tokens, but the adapter does not rewrite the raw values already stored in
your
Sessionbackend.
The adapter does not change trace_include_sensitive_data; whether spans carry data is your setting.
Runnable example
A guarded customer-support agent for Musterfirma GmbH whose lookup tool returns a raw account record
mid-run. The example asserts the tool's tracing span and the model's next-turn input carry only
tokens, then that the final reply restored the name and kept the IBAN masked per policy. A second run
on the log_safe channel produces a de-identified audit line. The full source is at
examples/openai-agents-python/guard.py.
from agents import Agent, function_tool
from guardai_sdk import OGuardAIClient
from guardai_sdk.integrations.openai_agents import GuardedRunner
ACCOUNT_RECORD = {
"name": "Max Mustermann",
"email": "max.mustermann@example.de",
"iban": "DE97 9900 0000 0123 4567 89",
}
@function_tool
def lookup_account(account_id: str) -> dict:
return dict(ACCOUNT_RECORD)
client = OGuardAIClient(base_url="http://localhost:3000")
agent = Agent(name="Account Support", instructions="...", tools=[lookup_account])
runner = GuardedRunner(client, policy="german-support", language="de")
runner.install(agent)
result = runner.run_sync(agent, "Please send the invoice copy for account ACC-4711.")
# result.output: "Guten Tag Herr Max Mustermann, ..." (name restored, IBAN masked)Prerequisites
Person detection and the German honorific restore need the NER detector, so start the OGuardAI server with the detector sidecar:
# From the repo root. The bundled stack requires these four secrets, and its client key ships
# least-privilege (channel user_output only), so widen it to the channels this example uses.
export GUARDAI_SESSION_SECRET=$(openssl rand -base64 32)
export GUARDAI_API_KEY_1=$(openssl rand -hex 32)
export GUARDAI_DETECTOR_API_KEY=$(openssl rand -hex 32)
export GUARDAI_REDIS_PASSWORD=$(openssl rand -hex 32)
export GUARDAI_API_KEY_1_ALLOWED_OUTPUT_CHANNELS=user_output,log_safe
docker compose -f deploy/docker/docker-compose.yml up -dThe Agents SDK is always required (pip install -r examples/openai-agents-python/requirements.txt).
Without OPENAI_API_KEY the example drives a deterministic scripted model, so no provider is
contacted; the guarded wiring is identical either way.
Run
export GUARDAI_BASE_URL="http://localhost:3000" # or GUARDAI_URL / GUARDAI_BASE
export GUARDAI_API_KEY="$GUARDAI_API_KEY_1" # the bundled stack runs api_key auth
export OPENAI_API_KEY="sk-..." # optional: drives a real model
python guard.py