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
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_callsarray and your client sends backrole: "tool"messages with results. - Responses API: the newer surface where the response's
outputarray contains items of typefunction_call, and your client submitsfunction_call_outputitems 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.v1with the kernel's Ed25519 signature and a stablereceipt_ref.
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.
[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:
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
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.
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
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.
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.
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 messagesThe 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
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 field | Chio manifest field | Notes |
|---|---|---|
function.name | tool.name | Verbatim; must be unique within the server |
function.description | tool.description | Verbatim; visible to the model and in receipts |
function.parameters | tool.input_schema | JSON Schema, preserved as-is |
| — | tool.output_schema | Optional; OpenAI tool specs do not carry this, so you add it |
| — | tool.has_side_effects | Must be asserted explicitly; controls capability requirements |
| — | tool.pricing | Optional; required for metered or commerce flows |
Given a plain OpenAI tool spec, the mapping into a ToolDefinition is mechanical:
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
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.
{
"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.
hushspec: "0.1.0"
name: openai-allowlist
rules:
tool_access:
enabled: true
default: block
allow:
- get_weather
- search_docs
- summarize_textDeny 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.
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: 60Require 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_callinstead of dispatching it directly in your agent code. - Convert results back to OpenAI shape with
results_to_messagesorresults_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