Chio/Docs
LOGIN · JOIN

BuildConnect

Govern OpenAI Tool Calls

Route OpenAI tool calls through Chio for capability checks, guard evaluation, and signed receipts.

Prerequisites

This guide assumes you have the Chio CLI installed. If not, see the Installation guide. You also need an OpenAI API key and an existing agent that uses either tool_calls (Chat Completions API) or the Responses API, and a model that supports function calling. The policy section needs neither: it runs against chio check with a --session-db and a --receipt-db, and a fresh session database per check.

How the OpenAI Adapter Works

Add the OpenAI adapter where your agent dispatches tool calls. Pass a model-selected call to the adapter instead of calling the tool directly. The adapter routes it through the kernel and supports both OpenAI APIs:

  • Chat Completions API: the API shape where the assistant message contains a tool_calls array and your client sends back role: "tool" messages with results.
  • Responses API: the newer API shape where the response's output array contains items of type function_call, and your client submits function_call_output items in the next turn.

For each extracted tool call, the adapter:

  • Validates the capability: the caller must present a capability token whose scope covers the chosen tool. No token, or a scope mismatch, and the call is denied.
  • Runs the guard pipeline: the kernel evaluates the configured guards against the tool name and arguments. Guards fail closed by default.
  • Signs a receipt: every decision, allow or deny, produces a chio.receipt.v1 with the kernel's Ed25519 signature and a stable receipt_ref.
rendering
The OpenAI API still chooses the tool; Chio decides whether the call happens. Denials never reach the tool backend.

The adapter lives in the chio-openai-adapter crate (published as the chio_openai library) as ChioOpenAiAdapter. Examples below use the crate's public API: openai_tools_json, extract_tool_calls, extract_responses_api_calls, execute_tool_call, results_to_messages, and results_to_responses_api.


Install the Adapter

The adapter ships as a Rust crate you embed in the same binary that hosts your Chio kernel. If your agent is a Python or TypeScript process, the common pattern is a thin Rust sidecar that owns the kernel and adapter, and exposes a small HTTP or stdio interface to your agent. For all-Rust agents, the adapter goes straight into the agent binary.

Cargo.tomltoml
[dependencies]
chio-openai-adapter = "0.1"
chio-kernel = "0.1"
chio-core = "0.1"
chio-manifest = "0.1"
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Bring up a kernel, register your tool servers, and construct an adapter over the manifests you want exposed through the OpenAI API:

src/main.rsrust
use chio_openai::{ChioOpenAiAdapter, OpenAiAdapterConfig};
use chio_kernel::ChioKernel;

fn main() -> anyhow::Result<()> {
    // Kernel boot is configured elsewhere (keypair, policy hash, etc.).
    let mut kernel = ChioKernel::new(kernel_config()?);

    // Register one or more tool servers. Each exposes a ToolManifest.
    let weather = Box::new(WeatherServer::new());
    let manifests = vec![weather.manifest().clone()];
    kernel.register_tool_server(weather);

    // Wrap the manifests in an OpenAI adapter.
    let adapter = ChioOpenAiAdapter::new(
        OpenAiAdapterConfig {
            server_id: "openai-front".into(),
            server_name: "OpenAI-facing kernel".into(),
            server_version: "1.0.0".into(),
            public_key: std::env::var("CHIO_SERVER_PUBLIC_KEY")?,
        },
        manifests,
    )?;

    // adapter.openai_tools_json() now produces the "tools" array
    // you send to the OpenAI API.
    run_agent_loop(&adapter, &kernel)
}

The adapter validates the merged manifest on construction. Duplicate tool names across input manifests are deduplicated by first occurrence; construction fails if the result would be an empty tool set.

Keep your OpenAI SDK calls

You do not replace openai.chat.completions.create or openai.responses.create. The adapter only intercepts the moment between the model choosing a tool and the tool running. The rest of your OpenAI integration is untouched: streaming, structured outputs, multi-turn memory, reasoning content.

Wire It Into a Chat Completions Call

The Chat Completions API has two hand-offs. You send tools in on the request; you receive tool calls back on the assistant message. The adapter feeds both sides.

Build the tools payload with openai_tools_json(), send the request, and extract tool calls from the assistant message with extract_tool_calls. Each extracted call goes through execute_tool_call; the results convert back to role: "tool" messages with results_to_messages.

src/chat_completions.rsrust
use chio_openai::{ChioOpenAiAdapter, OpenAiExecutionContext};
use chio_kernel::ChioKernel;
use serde_json::{json, Value};

pub async fn run_turn(
    adapter: &ChioOpenAiAdapter,
    kernel: &ChioKernel,
    execution: &OpenAiExecutionContext,
    messages: &mut Vec<Value>,
    http: &reqwest::Client,
) -> anyhow::Result<()> {
    // 1. Build the OpenAI request. The tools array is produced by the
    //    adapter directly from the underlying tool manifest.
    let body = json!({
        "model": "gpt-4o",
        "messages": messages,
        "tools": adapter.openai_tools_json(),
        "tool_choice": "auto",
    });

    let resp: Value = http
        .post("https://api.openai.com/v1/chat/completions")
        .bearer_auth(std::env::var("OPENAI_API_KEY")?)
        .json(&body)
        .send()
        .await?
        .json()
        .await?;

    let assistant = &resp["choices"][0]["message"];
    messages.push(assistant.clone());

    // 2. Extract any tool calls the model chose. Both extractors return a
    //    Result: a malformed tool_calls entry is an error, not an empty list.
    let tool_calls = ChioOpenAiAdapter::extract_tool_calls(assistant)?;
    if tool_calls.is_empty() {
        return Ok(()); // Final answer; nothing to mediate.
    }

    // 3. Every tool call is evaluated by the kernel before it runs.
    let results = adapter.execute_tool_calls(&tool_calls, kernel, execution);

    // 4. Append the tool results as role: "tool" messages for the next turn.
    for message in ChioOpenAiAdapter::results_to_messages(&results) {
        messages.push(message);
    }

    Ok(())
}

The shape of each ToolCallResult matches what OpenAI expects on the next turn: the tool_call_id is preserved, the content is either the tool output or the denial reason, and the denied flag plus receipt_ref let you route audit, alerts, or user-visible failure messages alongside the conversation. The full receipt is on receipt when there is one; receipt_ref is that receipt's id.

A preflight result produces no tool message

results_to_messages and results_to_responses_api skip any result whose preflight flag is set, so that call's tool_call_id gets no reply on the next turn and the OpenAI API rejects the conversation. If you use the preflight path, answer those ids yourself before you submit.

execute_tool_calls needs a multi-thread runtime

The execute path bridges into an async kernel dispatch from a synchronous call. On a current-thread Tokio runtime that bridge is refused and every call comes back denied with SyncBridgeIncompatibleWithCurrentThreadRuntime. The default #[tokio::main] runtime is multi-thread and is fine; #[tokio::test] defaults to current-thread and is not.

Denials become tool messages

A denied call still produces a role: "tool" message whose content is the denial reason. The model sees that and typically adjusts its next turn by asking for clarification, picking a different tool, or explaining to the user. Fail-closed behavior means the tool backend is not reached on a deny, but the model is still aware the call did not go through.

Wire It Into the Responses API

The Responses API differs on both the extraction side and the response side. Tool calls live in the output array as items of type function_call; you submit function_call_output items back. The adapter covers both with extract_responses_api_calls and results_to_responses_api.

src/responses_api.rsrust
use chio_openai::{ChioOpenAiAdapter, OpenAiExecutionContext};
use chio_kernel::ChioKernel;
use serde_json::{json, Value};

pub async fn run_turn(
    adapter: &ChioOpenAiAdapter,
    kernel: &ChioKernel,
    execution: &OpenAiExecutionContext,
    previous_id: Option<&str>,
    input: Value,
    http: &reqwest::Client,
) -> anyhow::Result<Value> {
    let body = json!({
        "model": "gpt-4o",
        "tools": adapter.openai_tools_json(),
        "input": input,
        "previous_response_id": previous_id,
    });

    let resp: Value = http
        .post("https://api.openai.com/v1/responses")
        .bearer_auth(std::env::var("OPENAI_API_KEY")?)
        .json(&body)
        .send()
        .await?
        .json()
        .await?;

    // The Responses API returns items inside "output". The adapter knows
    // how to pick out function_call entries and ignore everything else
    // (messages, reasoning items, refusals, and so on).
    let tool_calls = ChioOpenAiAdapter::extract_responses_api_calls(&resp)?;
    if tool_calls.is_empty() {
        return Ok(resp);
    }

    let results = adapter.execute_tool_calls(&tool_calls, kernel, execution);

    // Produce function_call_output items for the next turn.
    let outputs = ChioOpenAiAdapter::results_to_responses_api(&results);

    // Submit the outputs alongside the previous response id to continue.
    let follow_up = json!({
        "model": "gpt-4o",
        "previous_response_id": resp["id"],
        "input": outputs,
    });

    Ok(http
        .post("https://api.openai.com/v1/responses")
        .bearer_auth(std::env::var("OPENAI_API_KEY")?)
        .json(&follow_up)
        .send()
        .await?
        .json()
        .await?)
}

The two helpers that translate to and from the Responses API shape are protocol-specific. The code between extract_responses_api_calls and results_to_responses_api is the same kernel path as the Chat Completions flow: same guards, same capability checks, same receipt format.


provider-adapter Feature

The default API includes ChioOpenAiAdapter and its extract/execute/convert helpers, and it is always compiled. The crate ships an optional API behind the provider-adapter feature. Turn it on when your agent forwards native requests through Chio or streams tool calls over SSE.

Cargo.tomltoml
[dependencies]
chio-openai-adapter = { version = "0.1", features = ["provider-adapter"] }

The feature pulls in chio-tool-call-fabric and chio-provider-adapter-core and adds three capabilities:

  • Lift and lower provider calls. OpenAiAdapter implements chio_tool_call_fabric::ProviderAdapter (lift, lower, plus lift_batch), lifting Responses API function_call items into the shared ToolInvocation shape and lowering a kernel verdict back into OpenAI tool_outputs JSON.
  • SSE verdict gating. gate_sse_stream / GatedSseStream buffer a streamed tool-call block and release it to the caller only after its verdict allows. A denied call never surfaces mid-stream.
  • Outbound HTTP transport. Native /v1/responses and /v1/chat/completions requests are forwarded to the OpenAI API over the shared chio-provider-adapter-core transport, with a mock transport interface for hermetic tests.

Streaming pins the Responses API snapshot

Every provider-adapter entry point checks the configured api_version against the pinned constant OPENAI_RESPONSES_API_VERSION = "responses.2026-04-25" and rejects a mismatch. If a streaming agent passes a different Responses API version, the call is refused before it reaches the kernel. Set the version to the pinned snapshot, or leave it unset to take the default.

Python and TypeScript Agents

An agent outside Rust keeps its OpenAI call exactly as written and changes one block: the dispatch between the model choosing a tool and the tool running. That block talks to the sidecar over localhost. Two first-party packages cover it, and both are on their public registry:

bash
$ pip install chio-sdk-python
$ npm install @chio-protocol/ai-sdk

The dist chio-sdk-python installs the module chio_sdk, an async client for the sidecar that signs and evaluates nothing itself. @chio-protocol/ai-sdk wraps the Vercel AI SDK's tool() helper, so the gate sits at the execute entry point and streaming return values pass through unbuffered. Both are the same operation in two languages: evaluate the call before the side effect.

sdks/python/chio-sdk-python/README.md:25-57sdks/typescript/packages/ai-sdk/README.md:27-55
from chio_sdk import ChioClient
from chio_sdk.errors import ChioDeniedError


async def main() -> None:
    # Defaults to the local sidecar at http://127.0.0.1:9090.
    async with ChioClient() as client:
        await client.health()

        advisory = await client.evaluate_tool_call_advisory(
            capability_id="cap-123",
            tool_server="search-srv",
            tool_name="search_documents",
            parameters={"query": "capability-based security"},
        )
        print(f"advisory receipt: {advisory.id}")

        # `evaluate_tool_call` is fail-closed for id-only callers: it runs
        # advisory evaluation for audit, then always raises `ChioDeniedError`,
        # because a capability id alone is not execution authorization. It never
        # returns a receipt. Wrappers gate on this raise; use
        # `evaluate_tool_call_advisory` for a non-authoritative observation, or
        # `evaluate_tool_call_mediated` with a full signed token for
        # authoritative enforcement.
        try:
            await client.evaluate_tool_call(
                capability_id="cap-123",
                tool_server="search-srv",
                tool_name="search_documents",
                parameters={"query": "capability-based security"},
            )
        except ChioDeniedError as error:
            print(f"not authorized: {error}")

Both fail closed, and each surfaces the denial as its language's error. On the TypeScript side a denied call makes execute throw ChioToolError, which the Vercel AI SDK routes through onError and result.error. The sidecar being unreachable is also a deny, including when onSidecarError: "allow" is supplied: that option is a declared no-op, and the package says so in its own doc comment. The wrapper also refuses to run the tool until the allow receipt verifies, either through a verifyReceipt you supply or a reachable sidecar verify endpoint, so an allow you cannot check is treated the same as a deny.

The Python tab shows the id-only path, which is deliberately the loud one. A caller holding the full signed capability token drives the kernel-mediated route instead. That route verifies the token, reserves the budget hold so concurrent authorizations cannot over-subscribe, and mints a fresh execution nonce:

sdks/python/chio-sdk-python/src/chio_sdk/client.py:591-602python
    async def evaluate_tool_call_mediated(
        self,
        *,
        capability: dict,
        tool_server: str,
        tool_name: str,
        parameters: dict,
        request_id: str | None = None,
        governed_intent: dict[str, Any] | None = None,
        approval_token: dict[str, Any] | None = None,
        dpop_proof: dict[str, Any] | None = None,
    ) -> dict:

capability is the complete signed token, not an id-only {"id": ...} object. The return is {"status", "receipt", "execution_nonce"}, where status is authorized, deny, or pending_approval. Forward governed_intent, approval_token, and dpop_proof for grants that carry those requirements; without them such a grant denies. Present the nonce to the tool server, which consumes it, runs the tool, and reconciles the hold through reconcile_mediated_authorization.

Why a sidecar

The kernel owns signing keys and receipt state and needs to live in a trust boundary you control. A sidecar keeps that boundary outside your agent process, which means a compromised Python interpreter cannot forge receipts or elevate capabilities.

Derive a Tool Manifest From Your OpenAI Tool Spec

An OpenAI tool spec, the JSON you have been passing in the tools parameter, converts directly into a Chio tool manifest. The two schemas overlap almost completely: both use JSON Schema for parameters, both key tools by name, both carry a description.

OpenAI fieldChio manifest fieldNotes
function.nametool.nameVerbatim; must be unique within the server
function.descriptiontool.descriptionVerbatim; visible to the model. It stays in the manifest: a receipt records the tool name, not its description
function.parameterstool.input_schemaJSON Schema, preserved as-is
nonetool.output_schemaOptional; OpenAI tool specs do not carry this, so you add it
nonetool.has_side_effectsMust be asserted explicitly; controls capability requirements
nonetool.pricingOptional; required for metered or commerce flows

Given a plain OpenAI tool spec, the mapping into a ToolDefinition is mechanical. Chio ships no importer, so the converter below is yours to keep; it targets the manifest types at crates/platform/chio-manifest/src/lib.rs:30-144.

rust
use chio_manifest::{ToolDefinition, ToolManifest};
use serde_json::{json, Value};

/// Convert an OpenAI tools array into a Chio ToolManifest.
pub fn manifest_from_openai_tools(
    server_id: &str,
    public_key: &str,
    openai_tools: &[Value],
) -> ToolManifest {
    let tools = openai_tools
        .iter()
        .filter(|t| t["type"] == "function")
        .map(|t| {
            let f = &t["function"];
            ToolDefinition {
                name: f["name"].as_str().unwrap_or("").to_string(),
                description: f["description"].as_str().unwrap_or("").to_string(),
                // Validation requires an object here. An OpenAI tool with no
                // "parameters" key would clone Null and fail construction, so
                // substitute an empty schema.
                input_schema: match f.get("parameters") {
                    Some(schema) if schema.is_object() => schema.clone(),
                    _ => json!({"type": "object", "properties": {}}),
                },
                output_schema: None,
                pricing: None,
                // Assert this per-tool. Reads are false; writes and
                // external side effects are true. There is no safe
                // default here, so pick one deliberately.
                has_side_effects: false,
                latency_hint: None,
            }
        })
        .collect();

    ToolManifest {
        schema: "chio.manifest.v1".into(),
        server_id: server_id.into(),
        name: "Imported from OpenAI tools".into(),
        description: Some("Auto-derived manifest".into()),
        version: "1.0.0".into(),
        tools,
        // Anthropic hosted server tools. An OpenAI import has none, but the
        // field is not optional: ToolManifest derives no Default.
        server_tools: Vec::new(),
        required_permissions: None,
        public_key: public_key.into(),
    }
}

The manifest struct it fills in is this. Nothing in it is optional except what carries Option or a serde default, and it derives no Default, so every field has to be named:

crates/platform/chio-manifest/src/lib.rs30-63rust
pub struct ToolManifest {
    /// Schema version. Must equal [`TOOL_MANIFEST_SCHEMA`].
    pub schema: String,

    /// The server's unique identifier.
    pub server_id: chio_core::ServerId,

    /// Human-readable server name.
    pub name: String,

    /// Server description.
    pub description: Option<String>,

    /// Semantic version of this tool server.
    pub version: String,

    /// The tools this server provides.
    pub tools: Vec<ToolDefinition>,

    /// Provider-native server tools this manifest explicitly allows.
    ///
    /// Anthropic server tools are larger trust-boundary surfaces than regular
    /// client-hosted tools. They default to deny unless the manifest lists the
    /// stable logical tool name here.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub server_tools: Vec<ServerTool>,

    /// Permissions this server requires from the host environment
    /// (filesystem paths, network access, environment variables, etc.).
    pub required_permissions: Option<RequiredPermissions>,

    /// Hex-encoded Ed25519 public key of this tool server.
    pub public_key: String,
}

Feed the result to ChioOpenAiAdapter::new and your existing OpenAI agent is already governable, with no tool spec rewrite required. Construction validates the merged manifest: duplicate tool names across input manifests are dropped after the first, each tool's input_schema must be a JSON object, and an empty tool set is an error rather than an adapter that exposes nothing.

Side effects are not auto-inferred

OpenAI tool specs carry no signal for whether a function mutates state. The adapter cannot guess, and defaulting to has_side_effects: true would force every read behind a capability token. Classify each imported tool by hand when you derive the manifest, or annotate your source spec with a convention and honor it in the import.

What the Receipt Looks Like

Each execute_tool_call returns a ToolCallResult whose receipt field, when present, is a plain ChioReceipt: the same chio.receipt.v1 the kernel signs for every other adapter, returned unmodified. The OpenAI function name lands on the top-level tool_name; the parsed arguments land on action.parameters with their canonical hash in action.parameter_hash; and the adapter attaches route-selection metadata under metadata.route_selection.

example-receipt.jsonjson
{
  "id": "26f79fd6ef3c59ccdf309e17226061d9ce5ec00cc70c0d1b8f84512326f2a156",
  "timestamp": 1776981420,
  "capability_id": "cap-019dc0f1-8a20-7b52-9971-41a89cb23ac6",
  "tool_server": "weather",
  "tool_name": "get_weather",
  "action": {
    "parameters": { "location": "San Francisco" },
    "parameter_hash": "a3c8a200..."
  },
  "decision": { "verdict": "allow" },
  "receipt_kind": "mediated_decision",
  "boundary_class": "prevent",
  "tool_origin": "caller_executed",
  "redaction_mode": "none",
  "content_hash": "5b1f0a77...",
  "policy_hash": "c40a9f18...",
  "metadata": {
    "attribution": { "delegation_depth": 0, "grant_index": 0, "issuer_key": "...", "subject_key": "..." },
    "chio_receipt_signing_nonce": "rcpt-019dc0f1-8a22-...",
    "receipt_context": { "request_id": "openai-call_abc123" },
    "route_selection": {
      "routeSelectionId": "39482a15...",
      "decision": "select",
      "sourceProtocol": "open_ai",
      "requestedTargetProtocol": "native",
      "selectedRouteId": "native-route",
      "selectedTargetProtocol": "native",
      "selectedProtocols": ["open_ai", "native"],
      "candidates": [
        { "routeId": "native-route", "targetProtocol": "native",
          "selectedProtocols": ["open_ai", "native"], "available": true }
      ]
    }
  },
  "trust_level": "mediated",
  "kernel_key": "9ab3f7c0...",
  "signature": "d80c5a6f..."
}

Every hash above is abbreviated for the page; on the wire they are full lowercase hex with no prefix, 64 characters for a digest or public key and 128 for a signature. Everything else is the real field set: receipt_kind, boundary_class, tool_origin, redaction_mode and trust_level are always serialized, and metadata always carries the attribution block, the signing nonce and a receipt_context.request_id derived from the OpenAI tool-call id. The route_selection object is canonical route-planning evidence, camelCase, and always carries its id, the requested target and the full candidate list alongside the selection. For a captured receipt with that object on it, see Bridge Between Protocols.

The deny reason does not name the guard

A denial switches decision to {"verdict":"deny","reason":...,"guard":...}, and it is tempting to read guard as the guard that fired. It is not. A HushSpec policy compiles to a single registered guard, the pipeline, so the reason reads guard denied the request: guard "guard-pipeline" denied the request for every guard denial, and decision.guard reads kernel. The guard that fired is named once per receipt, as an evidence[].guard_name. Route alerts and dashboards off that field. The captures in the next section show both halves side by side.

The tool backend is not invoked on a deny. The model receives a denial message, and the receipt log records the attempted call.

For the receipt schema, signature-verification steps, and the list of enforced invariants, see Receipts and the Receipt format reference.


Policy Patterns

The policy that guards OpenAI tool calls is the same HushSpec policy you would write for any Chio deployment. A few patterns come up often enough to call out.

Allowlist by Tool Name

The single most common pattern: pin the set of tools the model may call, regardless of what the OpenAI tool spec advertises. Even if the model hallucinates a tool name or the spec grows a new entry, the call does not reach a backend.

openai-allowlist.yamlyaml
hushspec: "0.1.0"
name: openai-allowlist

rules:
  tool_access:
    enabled: true
    default: block
    allow:
      - get_weather
      - search_docs
      - summarize_text

The adapter hands each extracted call to the same kernel that chio check drives, so the CLI is the fastest way to see what a policy will do to a function name before a model ever picks it. An allowed name:

openai-policy · allowlist-allowtranscript
$ chio --session-db ./admission-0.db --receipt-db ./allowlist.db \
  check --policy ./openai-allowlist.yaml --tool get_weather \
  --params '{"location": "San Francisco"}'
verdict:    ALLOW
tool:       get_weather
server:     *
receipt_id: 188f52a58b29a1f3ac37d1531ecc767e5f5852e628a3cc4326e74ccd58ff5f9a
policy:     8d7d707ab9d7a93cdc46248e5b9517cd263a335598b9032a4d6c92e8500b705a
source:     ee7bc9290285fa2561e477f0aa8ad0f4ae44bfb9792e3f418cc2e2fc1fcd7675
mode:       preflight
fixture:    false
exit 0allow

And one that is not on the list:

openai-policy · allowlist-denytranscript
$ chio --session-db ./admission-1.db --receipt-db ./allowlist.db \
  check --policy ./openai-allowlist.yaml --tool delete_account \
  --params '{"user_id": "u-42"}'
verdict:    DENY
tool:       delete_account
server:     *
reason:     requested tool delete_account on server * is not in capability scope
receipt_id: 8fc104fd713f3ca1ba29fefd7c763847e436d9706e8618b3d8cd92c5fea8f3dc
policy:     8d7d707ab9d7a93cdc46248e5b9517cd263a335598b9032a4d6c92e8500b705a
source:     ee7bc9290285fa2561e477f0aa8ad0f4ae44bfb9792e3f418cc2e2fc1fcd7675
mode:       preflight
fixture:    false
exit 2deny

Guard by Action Class, Not by Argument

The obvious next move is to allow a tool in general and block it for specific argument shapes: queries that mutate, paths that touch secrets, URLs that egress. That works, but not the way the names suggest, and the difference is the single most useful thing to understand about guards on this page.

A guard does not see arguments in the abstract. The kernel first classifies the call into an action from the tool's name, and each guard evaluates one action class. shell_commands evaluates shell actions, which are the tool names bash, shell, run_command, exec, execute, run, shell_exec and terminal. egress evaluates network actions, which are http_request, fetch, curl, http, request and web_request. secret_patterns evaluates file writes and patches on the way in, and redacts tool results on the way back. A function whose name falls outside those sets is an ordinary tool call, and those guards never look at it.

Here is what that means in practice. This policy allows run_query and turns on both shell_commands with a DROP pattern and secret_patterns:

openai-argument-guards.yamlyaml
hushspec: "0.1.0"
name: openai-argument-guards

rules:
  tool_access:
    enabled: true
    default: block
    allow:
      - run_query
      - run_command
      - write_file

  shell_commands:
    enabled: true
    forbidden_patterns:
      - "(?i)\b(DROP|DELETE|TRUNCATE)\b"
      - "(?i)\bsudo\b"

  secret_patterns:
    enabled: true

A run_query whose argument is DROP TABLE customers passes:

openai-policy · sql-not-stoppedtranscript
$ chio --session-db ./admission-2.db --receipt-db ./guards.db \
  check --policy ./openai-argument-guards.yaml --tool run_query \
  --params '{"query": "DROP TABLE customers"}' \
  --mode full --output-fixture ./fixture.json
verdict:    ALLOW
tool:       run_query
server:     *
receipt_id: e941e44c5d25b51fa668cf4692a556875f149fd7f4cd3e2434933a6cfa096adf
policy:     154cade5c2d11ac059e5cbeed5936ee9fbb733097b530e026faf515a5334ead2
source:     e1f54d5ff34b71596e687e574b9a94424fe043bcca0510d79333ef3314f6d5ad
mode:       full
fixture:    true
exit 0allow

So does one carrying an AWS-shaped access key:

openai-policy · secret-not-stoppedtranscript
$ chio --session-db ./admission-3.db --receipt-db ./guards.db \
  check --policy ./openai-argument-guards.yaml --tool run_query \
  --params '{"query": "SELECT '\\''AKIAIOSFODNN7EXAMPLE'\\''"}' \
  --mode full --output-fixture ./fixture.json
verdict:    ALLOW
tool:       run_query
server:     *
receipt_id: ca1a571489fc54e0b07429e826001e712ccc3803622a2b400c4b7c70706fdf94
policy:     154cade5c2d11ac059e5cbeed5936ee9fbb733097b530e026faf515a5334ead2
source:     e1f54d5ff34b71596e687e574b9a94424fe043bcca0510d79333ef3314f6d5ad
mode:       full
fixture:    true
exit 0allow

Neither guard was bypassed. Neither guard was asked. run_query is not a shell name, so no shell action was built; it is not a file write, so the secret guard had nothing to inspect. Give the same policy a call it does classify and both fire:

openai-policy · shell-denytranscript
$ chio --session-db ./admission-4.db --receipt-db ./guards.db \
  check --policy ./openai-argument-guards.yaml --tool run_command \
  --params '{"command": "sudo rm -rf /var/data"}' \
  --mode full --output-fixture ./fixture.json
verdict:    DENY
tool:       run_command
server:     *
reason:     guard denied the request: guard "guard-pipeline" denied the request
receipt_id: 89edc0a71d25d78d4c7b86ebb3ec6f7462a4164c624b1d4f4169bcb101c7b8ff
policy:     154cade5c2d11ac059e5cbeed5936ee9fbb733097b530e026faf515a5334ead2
source:     e1f54d5ff34b71596e687e574b9a94424fe043bcca0510d79333ef3314f6d5ad
mode:       full
fixture:    true
exit 2deny
openai-policy · secret-denytranscript
$ chio --session-db ./admission-5.db --receipt-db ./guards.db \
  check --policy ./openai-argument-guards.yaml --tool write_file \
  --params '{"path": "./config.txt", "content": "AKIAIOSFODNN7EXAMPLE"}' \
  --mode full --output-fixture ./fixture.json
verdict:    DENY
tool:       write_file
server:     *
reason:     guard denied the request: guard "guard-pipeline" denied the request
receipt_id: 62688521024b6410f2e0b32d1f8ceb6b921db459bbef676b990e527084c9d793
policy:     154cade5c2d11ac059e5cbeed5936ee9fbb733097b530e026faf515a5334ead2
source:     e1f54d5ff34b71596e687e574b9a94424fe043bcca0510d79333ef3314f6d5ad
mode:       full
fixture:    true
exit 2deny

The receipts those four calls wrote say the same thing more compactly, and show where the guard name actually lives:

openai-policy · guard-evidencetranscript
$ chio --receipt-db ./guards.db receipt list --admin-all \
  | jq -c '{tool: .tool_name, verdict: .decision.verdict,
$             guard: .decision.guard,
$             evidence: [.evidence[]?.guard_name]}'
{"tool":"run_query","verdict":"allow","guard":null,"evidence":[]}
{"tool":"run_query","verdict":"allow","guard":null,"evidence":[]}
{"tool":"run_command","verdict":"deny","guard":"kernel","evidence":["shell-command"]}
{"tool":"write_file","verdict":"deny","guard":"kernel","evidence":["secret-leak"]}
exit 0allow

Two conclusions worth carrying away. First, if you want a guard to see an OpenAI function, name the function so it classifies, or give the backend a tool that does. Second, the tool allowlist is the control that always applies, because it is enforced as capability scope before any guard runs. Everything else depends on what kind of action the call turned out to be.

Require Human Approval for Side Effects

RequireApprovalAbove is a constraint you attach to a tool grant on the capability token, and it is a monetary gate, not a side-effect toggle. It compares its threshold_units against the max_amount on the request's governed transaction intent, so a call that carries no intent fails closed with RequireApprovalAbove requires a governed intent with max_amount rather than passing.

When the amount is over the threshold and no approval token accompanies the call, the kernel does not hold anything: it returns a terminal deny reading governed transaction denied: approval token required for governed transaction intent <id>, and the adapter turns that into a denied: true result with the same text as its content. Your agent surfaces that, collects an approval out of band, and retries with the approval token attached.

In a HushSpec policy the constraint is not hand-attached at all. The human_in_loop rule block compiles to it, which is the path to prefer when the policy file is already the source of truth.

See Write a Policy for the full list of guards and the semantics of each. For deeper capability-token construction, see Capabilities.


Verify the Result

Four checks that do not need a model in the loop.

  1. The adapter constructs. ChioOpenAiAdapter::new returns a Result. An error here means the merged manifest failed validation, most often an input_schema that is not a JSON object, or a manifest whose tool set came out empty.
  2. The tools array matches your manifest. Print adapter.openai_tools_json() and count the entries. It returns a Value built from the merged manifest, so a tool missing here was dropped as a duplicate name.
  3. One name on the allowlist passes. Run chio check against your policy with a tool the model is meant to call. The transcripts in the policy section are exactly that check.
  4. One name off it does not, and the receipt says why. Read the receipt back and confirm evidence[].guard_name names the guard you expected. An empty evidence array on a deny means the refusal came from capability scope, before any guard ran.

Failures and Recovery

What you seeWhat it means, and what to do
The example does not compile at tool_calls.is_empty()Both extractors return Result. Add ?. A malformed tool_calls entry is an error, and folding it into an empty list would silently skip the call.
A ToolManifest literal does not compileIt derives no Default, so there is no struct-update shorthand and every field has to be named, server_tools included.
Every call denies with SyncBridgeIncompatibleWithCurrentThreadRuntimeThe execute path is bridging into async dispatch on a current-thread Tokio runtime. Move it to a multi-thread runtime.
OpenAI rejects the next turn for a missing tool responseA preflight result was filtered out of the conversion helpers, leaving its tool_call_id unanswered. Answer it yourself.
A guard you enabled never denies anythingThe function name does not classify into that guard's action class. Guards are chosen by tool name, not by argument content.
A provider-adapter entry point refuses before the kernelThe configured api_version does not equal the pinned Responses API snapshot. Every entry point checks it. Set it to the pinned value, or leave it unset and take the default.
A deny receipt's decision.guard is always kernelWorking as built. The guard that fired is in evidence[].guard_name.

Summary

To govern an OpenAI agent with Chio:

  • Source your tools from the adapter instead of a hand-rolled JSON array, so the tool list is always derived from a validated Chio manifest.
  • Route every tool call through execute_tool_call instead of dispatching it directly in your agent code.
  • Convert results back to OpenAI shape with results_to_messages or results_to_responses_api, depending on which API you use.

This gives each tool call capability-scoped execution, guard evaluation, and a signed receipt.

Next Steps

  • Architecture · how the kernel, adapters, and tool servers fit together
  • Capabilities · the scope model that decides which tools a token can invoke
  • Write a Policy · HushSpec reference and the guards included with the preset
  • Bridge Between Protocols · route OpenAI tool calls to MCP, A2A, or ACP backends transparently