OGuardAI
Integrations

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 (across agent.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_sync transform 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 RunResult

run 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) -> agent wraps every reachable tool result. Idempotent and recursive across agent.tools, handoffs, and MCP servers; returns the agent for chaining.

  • run_config(cfg=None) -> RunConfig returns a RunConfig whose call_model_input_filter runs your own filter first and ours last. run / run_sync call it for you; call it yourself only when you drive Runner directly.

  • run_sync(agent, input, **kwargs) / run(agent, input, **kwargs) run the agent guarded and return a GuardedRunResult. Extra keyword arguments (context, max_turns, hooks, session, and so on) pass through to Runner.

  • scope() is a context manager for callers who drive Runner themselves. Open it, transform the input, run with run_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, and ComputerTool are 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_content replacement 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_tool nested agents: a nested agent run has its own tools. Call install on 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_span are 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 Session backend.

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

The 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