BuildConnect
Govern OpenAI Tool Calls
Route OpenAI tool calls through Chio for capability checks, guard evaluation, and signed receipts.
Prerequisites
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_callsarray and your client sends backrole: "tool"messages with results. - Responses API: the newer API shape where the response's
outputarray contains items of typefunction_call, and your client submitsfunction_call_outputitems 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.v1with the kernel's Ed25519 signature and a stablereceipt_ref.
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.
[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:
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
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.
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 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.
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.
[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.
OpenAiAdapterimplementschio_tool_call_fabric::ProviderAdapter(lift,lower, pluslift_batch), lifting Responses APIfunction_callitems into the sharedToolInvocationshape and lowering a kernel verdict back into OpenAItool_outputsJSON. - SSE verdict gating.
gate_sse_stream/GatedSseStreambuffer 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/responsesand/v1/chat/completionsrequests are forwarded to the OpenAI API over the sharedchio-provider-adapter-coretransport, with a mock transport interface for hermetic tests.
Streaming pins the Responses API snapshot
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.
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 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.
{
"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.
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_per_window: 100
window_secs: 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
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_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 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