Chio/Docs

Govern OpenAI Tool Calls

You have an OpenAI agent that already uses function calling or the Responses API. You want Chio to mediate every tool invocation — check the capability token, run the guards, sign a receipt — without rewriting the agent. The OpenAI adapter is built for exactly that. Your agent still calls openai.chat.completions.create or openai.responses.create; Chio sits between the model's chosen tool call and the tool backend, and every function invocation produces a signed chio.receipt.v1.

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. Any model that supports function calling works — GPT-4o, GPT-4 Turbo, GPT-5, and reasoning models are all fine.

How the OpenAI Adapter Works

The OpenAI adapter is a shim that plugs into the place in your agent loop where tool calls are dispatched. Instead of invoking your tool function directly when the model chooses one, you hand the tool call to the adapter, which routes it through the kernel. The adapter supports both OpenAI tool-calling surfaces:

  • Chat Completions API: the classic surface where the assistant message contains a tool_calls array and your client sends back role: "tool" messages with results.
  • Responses API: the newer surface 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 every tool call the adapter extracts from either surface, it does three things:

  • 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 crate as ChioOpenAiAdapter. Examples below ground every call on the crate's public surface — 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 the adapter, and exposes a small HTTP or stdio surface your agent talks to. For all-Rust agents, the adapter goes straight into the agent binary.

Cargo.toml
[dependencies]
chio-openai = "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 surface:

src/main.rs
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.

The OpenAI SDK never changes

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. Every other part of your OpenAI integration — streaming, structured outputs, multi-turn memory, reasoning content — is untouched.

Wire It Into a Chat Completions Call

The Chat Completions surface 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.rs
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.
    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.

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 — asking for clarification, picking a different tool, or explaining to the user. Fail-closed behavior here is the whole point: the tool backend is never 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.rs
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 only protocol-specific code is the two helpers that translate in and out of the Responses API shape. Everything 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.


Python and TypeScript Agents

If your agent is not written in Rust, treat the adapter as a local service. The Rust binary that hosts the kernel and the adapter exposes a small API — HTTP, gRPC, or stdio — and your agent code calls it at the two places where it used to dispatch tool calls itself. The change to an existing Python function-calling loop is small: nothing about the OpenAI SDK call changes, only the dispatch block between the model choosing a tool and the tool running.

agent.py
import json
from openai import OpenAI
from chio_openai_shim import get_tools, execute_tool_call  # calls the Rust sidecar

client = OpenAI()

def run_turn(messages, capability_token, agent_id):
    resp = client.chat.completions.create(
        model="gpt-4o",
        messages=messages,
        tools=get_tools(),          # manifest sourced from the adapter
        tool_choice="auto",
    )
    assistant = resp.choices[0].message
    messages.append(assistant.model_dump())

    if not assistant.tool_calls:
        return messages

    for call in assistant.tool_calls:
        # Every call is routed through Chio, not dispatched directly.
        result = execute_tool_call(
            tool_call_id=call.id,
            name=call.function.name,
            arguments=call.function.arguments,
            capability=capability_token,
            agent_id=agent_id,
        )
        messages.append({
            "role": "tool",
            "tool_call_id": result["tool_call_id"],
            "content": result["content"],  # denial reason if result["denied"]
        })
        # result["receipt_ref"] is the stable id of the signed receipt.

    return messages

The TypeScript shape is identical: your agent calls openai.chat.completions.create, the sidecar returns ToolCallResult values in JSON, and you append them as role: "tool" messages. This stays conceptual because the adapter crate is Rust; the sidecar is whatever thin wrapper fits your deployment.

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

If you already have an OpenAI tool spec — the JSON you have been passing in the tools parameter — you can produce a chio tool manifest directly from it. 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 and in receipts
function.parameterstool.input_schemaJSON Schema, preserved as-is
tool.output_schemaOptional; OpenAI tool specs do not carry this, so you add it
tool.has_side_effectsMust be asserted explicitly; controls capability requirements
tool.pricingOptional; required for metered or commerce flows

Given a plain OpenAI tool spec, the mapping into a ToolDefinition is mechanical:

src/import.rs
use chio_manifest::{ToolDefinition, ToolManifest};
use serde_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(),
                input_schema: f["parameters"].clone(),
                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 — 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,
        required_permissions: None,
        public_key: public_key.into(),
    }
}

Feed the resulting manifest to ChioOpenAiAdapter::new and your existing OpenAI agent is already governable — no tool spec rewrite required.

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 full chio.receipt.v1. The OpenAI function name lands on action.tool_name, the parsed arguments on action.parameters, and route-selection metadata is attached by the adapter itself.

example-receipt.json
{
  "version": "chio.receipt.v1",
  "receipt_id": "01HXYZ...9ZQ",
  "decision": "allow",
  "server_id": "test-srv",
  "agent_id": "ed25519:5f4e...a1b2",
  "action": {
    "tool_name": "get_weather",
    "parameters": {
      "location": "San Francisco"
    }
  },
  "metadata": {
    "route_selection": {
      "decision": "select",
      "selectedTargetProtocol": "native",
      "discoveryProtocol": "openai"
    },
    "adapter": "chio-openai"
  },
  "signature": "ed25519:a3b4c5d6..."
}

Denials carry the same structure with decision: "deny" and the failing guard's reason attached. The tool backend is never invoked on a deny, which is why fail-closed is safe: the model sees a denial message, the user sees the agent's reaction to that denial, and the receipt log records exactly what was tried.

For the full receipt schema, the 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 guard blocks anything not in the list.

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

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

Deny by Argument Pattern

Allow a tool in general, but block it for specific argument shapes — paths that touch secrets, queries that mutate, URLs that egress outside your approved list. The guards that ship in the code-agent preset cover the common cases; for OpenAI specifically, the secret_patterns guard catches secrets in arguments and shell_commands catches destructive SQL or shell strings even when they arrive as function arguments rather than shell invocations.

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

rules:
  tool_access:
    enabled: true
    default: block
    allow:
      - run_query
      - fetch_url

  shell_commands:
    enabled: true
    # Applied to tool arguments, not just shell tools. A run_query call
    # whose arguments contain "DROP TABLE" fails here.
    forbidden_patterns:
      - "(?i)\\b(DROP|DELETE|TRUNCATE)\\b"

  egress:
    enabled: true
    allow:
      - "api.internal.example.com"
      - "cdn.example.com"

  secret_patterns:
    enabled: true

  velocity:
    enabled: true
    max_invocations: 100
    window_seconds: 60

Require Human Approval for Side Effects

For tools marked has_side_effects: true, attach a RequireApprovalAbove constraint to the capability token. The kernel holds the call until a governed approval token arrives, and the adapter returns a denied: true result whose content explains that approval is pending. Your agent can surface that verbatim to the user.

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


Summary

Governing an OpenAI agent with chio means three small changes:

  • 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 surface you are using.

In exchange you get capability-scoped execution, a full guard pipeline, and a signed, non-repudiable receipt for every function call the model ever makes.

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 · comprehensive HushSpec reference and every guard the preset bundles
  • Bridge Between Protocols · route OpenAI tool calls to MCP, A2A, or ACP backends transparently
Govern OpenAI Tool Calls · Chio Docs