Chio/Docs

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. Use a model that supports function calling.

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.toml
[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.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.

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 — streaming, structured outputs, multi-turn memory, reasoning content — is untouched.

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.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 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.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 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 — which 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.toml
[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

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 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.json
{
  "id": "rcpt-019dc0f1-8a34-7b90-9c21-5e7043ab19c2",
  "timestamp": 1776981420,
  "capability_id": "cap-019dc0f1-8a20-...",
  "tool_server": "weather",
  "tool_name": "get_weather",
  "action": {
    "parameters": { "location": "San Francisco" },
    "parameter_hash": "a3c8a200..."
  },
  "decision": { "verdict": "allow" },
  "receipt_kind": "mediated_decision",
  "content_hash": "5b1f0a77...",
  "policy_hash": "c40a9f18...",
  "metadata": {
    "route_selection": {
      "decision": "select",
      "sourceProtocol": "open_ai",
      "selectedTargetProtocol": "native",
      "selectedProtocols": ["open_ai", "native"]
    }
  },
  "trust_level": "mediated",
  "kernel_key": "9ab3f7c0...",
  "signature": "d80c5a6f..."
}

Denials carry the same structure, with the decision object switching to {"verdict":"deny","reason":...,"guard":...} so it names the guard that failed. 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 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_per_window: 100
    window_secs: 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

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