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, 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_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 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.
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
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
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.
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.
[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
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:
$ pip install chio-sdk-python
$ npm install @chio-protocol/ai-sdkThe 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.
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}")import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { chioTool } from "@chio-protocol/ai-sdk";
const searchTool = chioTool({
description: "Search the web",
parameters: z.object({
query: z.string().describe("search query"),
}),
execute: async ({ query }) => {
return await fetchSearchResults(query);
},
scope: {
toolServer: "web-tools",
toolName: "search",
capabilityToken: process.env.CHIO_CAPABILITY_TOKEN!,
},
});
const result = streamText({
model: openai("gpt-4o"),
tools: { search: searchTool },
prompt: "Research quantum computing advances",
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}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:
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
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 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. It stays in the manifest: a receipt records the tool name, not its description |
function.parameters | tool.input_schema | JSON Schema, preserved as-is |
| none | tool.output_schema | Optional; OpenAI tool specs do not carry this, so you add it |
| none | tool.has_side_effects | Must be asserted explicitly; controls capability requirements |
| none | tool.pricing | Optional; 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.
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:
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
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": "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
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.
hushspec: "0.1.0"
name: openai-allowlist
rules:
tool_access:
enabled: true
default: block
allow:
- get_weather
- search_docs
- summarize_textThe 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:
$ 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
And one that is not on the list:
$ 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
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:
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: trueA run_query whose argument is DROP TABLE customers passes:
$ 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.jsonverdict: ALLOW tool: run_query server: * receipt_id: e941e44c5d25b51fa668cf4692a556875f149fd7f4cd3e2434933a6cfa096adf policy: 154cade5c2d11ac059e5cbeed5936ee9fbb733097b530e026faf515a5334ead2 source: e1f54d5ff34b71596e687e574b9a94424fe043bcca0510d79333ef3314f6d5ad mode: full fixture: true
So does one carrying an AWS-shaped access key:
$ 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.jsonverdict: ALLOW tool: run_query server: * receipt_id: ca1a571489fc54e0b07429e826001e712ccc3803622a2b400c4b7c70706fdf94 policy: 154cade5c2d11ac059e5cbeed5936ee9fbb733097b530e026faf515a5334ead2 source: e1f54d5ff34b71596e687e574b9a94424fe043bcca0510d79333ef3314f6d5ad mode: full fixture: true
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:
$ 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.jsonverdict: 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
$ 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.jsonverdict: 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
The receipts those four calls wrote say the same thing more compactly, and show where the guard name actually lives:
$ 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"]}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.
- The adapter constructs.
ChioOpenAiAdapter::newreturns aResult. An error here means the merged manifest failed validation, most often aninput_schemathat is not a JSON object, or a manifest whose tool set came out empty. - The tools array matches your manifest. Print
adapter.openai_tools_json()and count the entries. It returns aValuebuilt from the merged manifest, so a tool missing here was dropped as a duplicate name. - One name on the allowlist passes. Run
chio checkagainst your policy with a tool the model is meant to call. The transcripts in the policy section are exactly that check. - One name off it does not, and the receipt says why. Read the receipt back and confirm
evidence[].guard_namenames the guard you expected. An emptyevidencearray on a deny means the refusal came from capability scope, before any guard ran.
Failures and Recovery
| What you see | What 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 compile | It derives no Default, so there is no struct-update shorthand and every field has to be named, server_tools included. |
Every call denies with SyncBridgeIncompatibleWithCurrentThreadRuntime | The 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 response | A preflight result was filtered out of the conversion helpers, leaving its tool_call_id unanswered. Answer it yourself. |
| A guard you enabled never denies anything | The 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 kernel | The 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 kernel | Working 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_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