Chio/Docs

Govern OpenAI Tool Calls

Route each function call your OpenAI model proposes through the kernel, and return results, refusals, and receipts in OpenAI's format.

Guide chio@5f63e6e

On this page

Your OpenAI model still chooses which function to call, and no tool runs until the kernel admits that call. The kernel checks the caller's capability and policy, dispatches the registered tool, and signs a receipt.

You keep your model loop. ChioOpenAiAdapter, a Rust library embedded in your host, reads calls from Chat Completions and the Responses API and answers each one under its original call ID. Python and TypeScript agents reach the same kernel through a sidecar.

Start

The kernel owns dispatch, and the model owns the choice

The adapter resolves the function name to a registered tool server and builds a ToolCallRequest whose request ID is openai- followed by the OpenAI call ID. The kernel admits or refuses it, and only the kernel dispatches.

OpenAI tool callsChoose an outcome.

κcapabilityσscopegguardsBbudget

The kernel owns dispatch, not the model response

ChioOpenAiAdapter reads the call from Chat Completions tool_calls or a Responses API function_call item and builds a ToolCallRequest with request_id openai-{call id}. The kernel admits it and dispatches the registered tool, and the result’s receipt_ref names the signed receipt. Govern OpenAI Tool Calls.

One OpenAI function call through ChioOpenAiAdapter to the kernel and a registered tool server. Batches, which run sequentially in input order, and native transport are left out.

An allowed call returns the tool output with receipt_ref naming the signed receipt. A refused call returns denied: true with the reason as its content, and the tool never runs. A function with no binding in the manifest, or arguments that do not parse as JSON, is refused by the adapter itself, with no kernel call and no receipt. A batch runs sequentially in input order.

Register every protected handler with the kernel and send every model call through the adapter; a route that skips it sits outside the mediation boundary.

1. Run a complete host

Download the embedded Rust project and run it. Its count_words tool holds a capability for two calls, so the third is refused. This first run makes no model request.

Terminalbash
cd embedded-agent
cargo run --release --locked -- \
  "Chio governs useful work."
Output
call 1: ALLOW
{"words":4}
receipt: receipt-1.json
call 2: ALLOW
{"words":4}
receipt: receipt-2.json
call 3: DENY
invocation budget exhausted for capability cap-01a8…31f2
receipt: receipt-3.json

You should see two ALLOW decisions that each count four words, then one DENY (output illustrative). The host writes each signed receipt to receipt-1.json through receipt-3.json, and exits with an error if the refused call reached the handler.

Before you begin

The project needs Rust 1.94.1; Cargo fetches its pinned dependencies and the lockfile travels with the source. See Installation. Live calls also need OPENAI_API_KEY and OPENAI_MODEL set to a tool-capable model on your account. The offline run, the source tests, and the policy captures in step 5 need neither.

Set those two variables and the model chooses the calls. The live mode returns tool results and refusals to Chat Completions, saves receipts under receipts/, and stops after four model turns.

Terminalbash
cargo run --release --locked -- --live \
  "Count the words in: Chio governs useful work."

2. Build the adapter from your tool manifests

src/host.rs in the project creates the kernel, registers the tool, validates its manifest, and issues the caller's capability. Its Host hands your model loop three objects.

Your model looprust
let host = host::boot()?;
let result = host.adapter.execute_tool_call(
    &model_selected_call,
    &host.kernel,
    &host.execution,
);

To build the adapter yourself, construct it from the same manifests your registered tool servers use. The exported Rust library is chio_openai; the project's Cargo manifest pins it.

adapter.rsrust
use chio_manifest::ToolManifest;
use chio_openai::{ChioOpenAiAdapter, OpenAiAdapterConfig};

pub fn make_adapter(
    manifests: Vec<ToolManifest>,
    kernel_public_key: String,
) -> anyhow::Result<ChioOpenAiAdapter> {
    Ok(ChioOpenAiAdapter::new(
        OpenAiAdapterConfig {
            server_id: "openai-front".into(),
            server_name: "OpenAI-facing kernel".into(),
            server_version: "1.0.0".into(),
            public_key: kernel_public_key,
        },
        manifests,
    )?)
}

Construction validates the merged manifest and refuses an empty tool set. Duplicate function names keep the first binding, so name tools uniquely across servers. Capabilities authorize the underlying manifest's server_id, never the adapter's openai-front identifier.

Each call also takes an OpenAiExecutionContext: the full signed capability, the bound agent identifier, and any proof, approval, model metadata, or nonce the grant requires. Run on the default multi-thread #[tokio::main] runtime; on a current-thread runtime every call returns SyncBridgeIncompatibleWithCurrentThreadRuntime.

3. Mediate a Chat Completions turn

Send openai_tools_json() as the request's tools, extract the assistant's calls with extract_tool_calls, pass each to execute_tool_call, and convert the results back to role: "tool" messages with results_to_messages.

embedded-agent/src/chat_completions.rsL1–90rust
use crate::turn::{append_ready, PendingCall};
use chio_kernel::ChioKernel;
use chio_openai::{ChioOpenAiAdapter, OpenAiExecutionContext};
use serde_json::{json, Value};
use std::path::PathBuf;

pub enum TurnOutcome {
    FinalAnswer,
    Ready,
    Pending {
        journal: PathBuf,
        calls: Vec<PendingCall>,
    },
}

pub async fn run_turn(
    adapter: &ChioOpenAiAdapter,
    kernel: &ChioKernel,
    execution: &OpenAiExecutionContext,
    messages: &mut Vec<Value>,
    http: &reqwest::Client,
    model: &str,
) -> anyhow::Result<TurnOutcome> {
    let key = std::env::var("OPENAI_API_KEY")?;
    run_turn_at(
        adapter,
        kernel,
        execution,
        messages,
        http,
        model,
        ("https://api.openai.com/v1/chat/completions", &key),
    )
    .await
}

async fn run_turn_at(
    adapter: &ChioOpenAiAdapter,
    kernel: &ChioKernel,
    execution: &OpenAiExecutionContext,
    messages: &mut Vec<Value>,
    http: &reqwest::Client,
    model: &str,
    provider: (&str, &str),
) -> anyhow::Result<TurnOutcome> {
    anyhow::ensure!(
        serde_json::to_vec(messages)?.len() <= 160_000,
        "conversation exceeds 160 KB"
    );
    let mut response = http
        .post(provider.0)
        .bearer_auth(provider.1)
        .json(&json!({"model":model,"messages":messages,"tools":adapter.openai_tools_json(),"tool_choice":"auto"}))
        .send().await?.error_for_status()?;
    let mut bytes = Vec::new();
    while let Some(chunk) = response.chunk().await? {
        anyhow::ensure!(
            bytes.len().saturating_add(chunk.len()) <= 256_000,
            "provider response exceeds 256 KB"
        );
        bytes.extend_from_slice(&chunk);
    }
    let body: Value = serde_json::from_slice(&bytes)?;
    let assistant = body
        .pointer("/choices/0/message")
        .filter(|message| message["role"] == "assistant")
        .ok_or_else(|| anyhow::anyhow!("missing assistant message"))?;
    let calls = ChioOpenAiAdapter::extract_tool_calls(assistant)?;
    messages.push(assistant.clone());
    if calls.is_empty() {
        return Ok(TurnOutcome::FinalAnswer);
    }

    let (journal, outcome) = crate::turn::mediate(adapter, kernel, execution, &calls)?;
    append_ready(messages, &outcome);
    let still_pending = outcome.pending;
    // Persist the complete continuation before another provider request.
    chio_example_runtime::files::create(
        &journal.directory.join("conversation.json"),
        &serde_json::to_vec_pretty(messages)?,
    )?;
    if still_pending.is_empty() {
        Ok(TurnOutcome::Ready)
    } else {
        Ok(TurnOutcome::Pending {
            journal: journal.directory,
            calls: still_pending,
        })
    }
}

Open the complete source file

You should see one ToolCallResult per call, in OpenAI's shape for the next turn: the tool_call_id preserved, content holding the output or the refusal reason, and denied plus receipt_ref for routing audit, alerts, or user-facing messages. The full receipt is on receipt when one exists. A pending call keeps its ID and nonce; Retained batches covers retry and recovery.

4. Mediate a Responses API turn

The Responses API carries calls in its output array as function_call items and takes function_call_output items back. extract_responses_api_calls and results_to_responses_api translate both ways; between them runs the same kernel path, guards, capability checks, and receipt format.

embedded-agent/src/responses_api.rsL1–77rust
use crate::chat_completions::TurnOutcome;
use chio_kernel::ChioKernel;
use chio_openai::{ChioOpenAiAdapter, OpenAiExecutionContext};
use serde_json::{json, Value};

pub struct ResponseState {
    pub previous_id: Option<String>,
    pub input: Value,
}

pub async fn run_turn(
    adapter: &ChioOpenAiAdapter,
    kernel: &ChioKernel,
    execution: &OpenAiExecutionContext,
    state: &mut ResponseState,
    http: &reqwest::Client,
    model: &str,
) -> anyhow::Result<TurnOutcome> {
    anyhow::ensure!(
        serde_json::to_vec(&state.input)?.len() <= 160_000,
        "response input exceeds 160 KB"
    );
    let tools: Vec<Value> = adapter
        .openai_tools()
        .into_iter()
        .map(|tool| {
            json!({
                "type":"function","name":tool.function.name,"description":tool.function.description,
                "parameters":tool.function.parameters,"strict":false,
            })
        })
        .collect();
    let mut response = http.post("https://api.openai.com/v1/responses")
        .bearer_auth(std::env::var("OPENAI_API_KEY")?)
        .json(&json!({"model":model,"tools":tools,"input":state.input,"previous_response_id":state.previous_id}))
        .send().await?.error_for_status()?;
    let mut bytes = Vec::new();
    while let Some(chunk) = response.chunk().await? {
        anyhow::ensure!(
            bytes.len().saturating_add(chunk.len()) <= 256_000,
            "response exceeds 256 KB"
        );
        bytes.extend_from_slice(&chunk);
    }
    let response: Value = serde_json::from_slice(&bytes)?;
    let previous = response["id"]
        .as_str()
        .ok_or_else(|| anyhow::anyhow!("provider returned no response ID"))?
        .to_owned();
    let calls = ChioOpenAiAdapter::extract_responses_api_calls(&response)?;
    if calls.is_empty() {
        state.previous_id = Some(previous);
        state.input = response;
        return Ok(TurnOutcome::FinalAnswer);
    }
    let (journal, outcome) = crate::turn::mediate(adapter, kernel, execution, &calls)?;
    let mut outputs = ChioOpenAiAdapter::results_to_responses_api(&outcome.completed);
    outputs.extend(ChioOpenAiAdapter::results_to_responses_api(
        &outcome.refused,
    ));
    let continuation = json!({"previous_response_id":previous,"input":outputs,"pending_calls":outcome.pending.iter().map(|pending|&pending.call).collect::<Vec<_>>()});
    chio_example_runtime::files::create(
        &journal.directory.join("continuation.json"),
        &serde_json::to_vec_pretty(&continuation)?,
    )?;
    if !outcome.pending.is_empty() {
        return Ok(TurnOutcome::Pending {
            journal: journal.directory,
            calls: outcome.pending,
        });
    }
    // A failed subsequent provider request can reuse this retained continuation;
    // it must not execute the completed tools again.
    state.previous_id = Some(previous);
    state.input = serde_json::to_value(outputs)?;
    Ok(TurnOutcome::Ready)
}

Open the complete source file

You should see one turn retain its tool outcomes and prepare the next continuation, which the loop sends once every pending call is handled. Run the host with --responses to take this path. The explicit strict: false keeps ordinary manifest schemas; strict mode needs schemas that meet OpenAI's additional constraints.

5. Prove one allowed call and one refusal

A HushSpec policy governs OpenAI tool calls as it governs any other call. This one admits three function names and blocks the rest.

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

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

chio check evaluates calls against it through the kernel, with no model or tool backend involved, so you can test the policy before provider traffic arrives.

A listed functiontranscript
$ 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
exit 0allow
A function outside the listtranscript
$ 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
exit 2deny

You should see get_weather admitted and delete_account refused as outside the capability scope, each with its receipt ID and policy hash. An embedded host installs the same policy and issues matching grants itself; a YAML file in an agent project configures no kernel.

Read more in Write a Policy.

6. Read each result before you return it

ToolCallResult keeps the provider's call ID. Check which of four outcomes it carries before treating its content as tool output.

  • denied: false, preflight: false. The kernel executed the tool and allowed the result. Return the content under the call ID and keep the receipt.
  • preflight: true. The tool has not run, and the response carries a nonce for one bound retry. Keep the call pending, attach its nonce in execution_nonces, and complete the retry; both conversion helpers omit this result.
  • denied: true with a receipt. The kernel refused. An output refusal can follow tool execution, so read the receipt's decision, terminal evidence, and guard evidence.
  • denied: true with no receipt. Adapter validation, routing, or kernel execution failed before a receipt existed. Keep the error as operational evidence.

receipt_ref is the receipt ID and receipt the complete ChioReceipt; route-selection metadata records the OpenAI-to-native route. For a guard denial, the guard that fired is in evidence[].guard_name, while decision.guard names the kernel or the aggregate pipeline. Check the signature against a kernel key you trust independently. A receipt records the kernel's decision; it shows neither that a weather answer is true nor that an external effect can be rolled back.

Read more in Receipts and Receipt Format.

7. Verify the boundary in your own host

Drive known calls through the adapter before a live provider sees it.

  1. Allowed. Feed a known allowed function call. Check the handler count, the content, the matching call ID, and the receipt signature.
  2. Out of scope. Keep the handler registered and issue a capability without that tool. Assert a refusal, an unchanged handler count, and the deny receipt.
  3. Malformed. Send bad arguments and an unknown function. Both are refused before any handler, with no signed receipt.
  4. Live. Run one provider turn and confirm each proposed call returns through the adapter as a result or refusal with its receipt.

You should see the handler count move only for the allowed call, and a receipt for every call the kernel decided. The adapter evidence record lists the source-level checks behind these behaviors.

Python and TypeScript agents reach the kernel through a sidecar

An agent in another language asks a kernel in a separate process to authorize each call; the caller and tool server honor the decision, consume its execution nonce, and reconcile the result.

BindingLanguageKernel runsOutcomes
ChioOpenAiAdapterRustIn your host processResult, refusal, preflight
evaluate_tool_call_mediatedPythonSidecarauthorized, deny, pending_approval
chioToolTypeScriptSidecarResult or ChioToolError

In TypeScript, chioTool from @chio-protocol/ai-sdk wraps an AI SDK tool so the sidecar at http://127.0.0.1:9090 evaluates each call before execute runs.

agent.tstypescript
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { chioTool } from "@chio-protocol/ai-sdk";

const countWords = chioTool({
  description: "Count whitespace-separated words in text",
  parameters: z.object({ text: z.string() }),
  execute: async ({ text }) => ({ words: text.split(/\s+/).filter(Boolean).length }),
  scope: {
    toolServer: "text-tools",
    toolName: "count_words",
    capabilityToken: process.env.CHIO_CAPABILITY_TOKEN!,
  },
});

const result = streamText({
  model: openai(process.env.OPENAI_MODEL!),
  tools: { count_words: countWords },
  prompt: "Count the words in: Chio governs useful work.",
});
for await (const chunk of result.textStream) process.stdout.write(chunk);

The wrapper needs the signed capability token and verified authorizing evidence; an unreachable sidecar or a failed verification refuses execution, and a denial throws ChioToolError. In Python, only an authorized result proceeds through the nonce-aware executor, and evaluate_tool_call with a capability ID alone is advisory. A separate kernel process keeps signing keys out of the agent process when the operating system enforces that separation.

Read more in Python SDK and TypeScript SDK.

OpenAI adapter reference

Cargo manifest

The project holds Cargo.toml, src/host.rs, and src/main.rs, pins its public Chio dependencies, and ships its lockfile. Keep this manifest while you follow the steps.

embedded-agent/Cargo.tomlL1–20toml
[package]
name = "chio-embedded-agent"
version = "0.1.0"
edition = "2021"

[workspace]

[dependencies]
chio-example-runtime = { path = "runtime" }
chio-core = { package = "chio-core-types", git = "https://github.com/backbay-labs/chio", rev = "5b8bec41d32f3838b880576fe6123c983ecebf8d" }
chio-kernel = { git = "https://github.com/backbay-labs/chio", rev = "5b8bec41d32f3838b880576fe6123c983ecebf8d" }
chio-manifest = { git = "https://github.com/backbay-labs/chio", rev = "5b8bec41d32f3838b880576fe6123c983ecebf8d" }
chio-openai-adapter = { git = "https://github.com/backbay-labs/chio", rev = "5b8bec41d32f3838b880576fe6123c983ecebf8d" }
anyhow = "1"
async-trait = "0.1"
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
reqwest = { version = "0.12", features = ["json"] }

uuid = { version = "1", features = ["v4"] }

Open the complete source file

The example keeps authority and accounting state in memory for one process and saves returned receipts as files, which restore no runtime state. Configure durable stores and persistent keys for a long-lived host. Change TextTools::invoke to implement your operation, and change its manifest and grant together.

Retained batches and pending calls

The turn function returns a typed outcome. Completed calls and refusals stay in the conversation; a pending call keeps its original ID and nonce. The shared mediation function records intent and every result before it retries only an eligible pending call, and hands the host any calls that still cannot continue, with their journal path.

embedded-agent/src/turn.rsL144–173rust
pub fn mediate(
    adapter: &ChioOpenAiAdapter,
    kernel: &chio_kernel::ChioKernel,
    execution: &chio_openai::OpenAiExecutionContext,
    calls: &[OpenAiToolCall],
) -> Result<(TurnJournal, BatchOutcome)> {
    let journal = TurnJournal::start(Path::new("receipts/turns"), calls)?;
    let results = adapter.execute_tool_calls(calls, kernel, execution);
    retain_batch(&journal, calls, &results)?;
    let mut outcome = classify(calls, results)?;
    let pending = std::mem::take(&mut outcome.pending);
    for pending in pending {
        let Some(nonce) = pending.preflight.execution_nonce.clone() else {
            outcome.pending.push(pending);
            continue;
        };
        let mut retry = execution.clone();
        retry.execution_nonces.clear();
        retry
            .execution_nonces
            .insert(pending.call.id.clone(), nonce);
        let result = adapter.execute_tool_call(&pending.call, kernel, &retry);
        journal.retain(&pending.call, &result, 1)?;
        let retried = classify(&[pending.call], vec![result])?;
        outcome.completed.extend(retried.completed);
        outcome.refused.extend(retried.refused);
        outcome.pending.extend(retried.pending);
    }
    Ok((journal, outcome))
}

Open the complete source file

A journal intent with no retained result is unresolved: inspect the kernel record and the protected resource before retrying. A storage or provider error leaves an executed sibling call executed. A nonce, an approval token, or supplemental authorization can be bound to one call ID; the adapter refuses a batch that reuses one across calls, so execute those calls individually and never replay completed calls when you recover a partial batch.

Action classes and built-in guards

The kernel classifies each call into an action from the tool's name, and each guard evaluates one action class. shell_commands evaluates shell actions: tools named bash, shell, run_command, exec, execute, run, shell_exec, and terminal. egress evaluates network actions: 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 outside those sets is an ordinary tool call, and those guards leave it alone.

This policy allows run_query and enables shell_commands with a DROP pattern and secret_patterns.

openai-argument-guards.yamlyaml
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: true

A run_query carrying DROP TABLE customers is admitted, and so is one carrying an AWS-shaped access key: run_query is no shell name and no file write, so neither guard has an action to inspect.

run_query with DROP TABLEtranscript
$ 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.json
verdict:    ALLOW
tool:       run_query
server:     *
receipt_id: e941e44c5d25b51fa668cf4692a556875f149fd7f4cd3e2434933a6cfa096adf
policy:     154cade5c2d11ac059e5cbeed5936ee9fbb733097b530e026faf515a5334ead2
source:     e1f54d5ff34b71596e687e574b9a94424fe043bcca0510d79333ef3314f6d5ad
mode:       full
fixture:    true
exit 0allow
run_query with an access keytranscript
$ 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.json
verdict:    ALLOW
tool:       run_query
server:     *
receipt_id: ca1a571489fc54e0b07429e826001e712ccc3803622a2b400c4b7c70706fdf94
policy:     154cade5c2d11ac059e5cbeed5936ee9fbb733097b530e026faf515a5334ead2
source:     e1f54d5ff34b71596e687e574b9a94424fe043bcca0510d79333ef3314f6d5ad
mode:       full
fixture:    true
exit 0allow

Give the same policy calls it classifies, and both guards refuse.

run_command with DROPtranscript
$ 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.json
verdict:    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
exit 2deny
write_file with a secrettranscript
$ 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.json
verdict:    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
exit 2deny

The receipts for those four calls show where the guard name lives: decision.guard reads kernel, and the guard that fired is in evidence[].guard_name. An empty evidence list alone does not identify a denial's cause.

Guard evidence on the four receiptstranscript
$ 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"]}
exit 0allow

Expose the documented action shape for built-in checks, or write a guard for your tool's own semantics, and test the exact function name and argument shape you expose. The CLI captures compile the allowlist into capability scope.

Approval for governed spending

RequireApprovalAbove is a monetary constraint on a tool grant in the capability token. It compares its threshold_units with the max_amount on the request's governed transaction intent, and a call with no intent fails closed with RequireApprovalAbove requires a governed intent with max_amount.

Above the threshold with no approval token, the kernel returns a terminal deny reading governed transaction denied: approval token required for governed transaction intent <id>, which the adapter returns as denied: true with the same text. The agent surfaces it, a person approves the intent, and the agent retries with the approval token attached. In HushSpec, the human_in_loop rule block compiles to this constraint, which is the path to prefer when the policy file is the source of truth. Capabilities covers token construction.

Manifests from existing function schemas

An OpenAI tool definition converts directly into a Chio tool manifest: both describe parameters in JSON Schema, key tools by name, and carry a description.

OpenAI fieldChio manifest fieldNotes
function.name
tool.name
Verbatim; unique within the server
function.description
tool.description
Verbatim; the receipt records the name, not the description
function.parameters
tool.input_schema
JSON Schema, preserved as is
none
tool.output_schema
Imported from Responses when present
none
tool.has_side_effects
Declared explicitly; sets capability requirements
none
tool.pricing
Optional; needed for metered or commerce flows

ToolManifest::from_openai_tools accepts Chat Completions' nested function definitions and Responses' flat ones. OpenAI function schemas never say whether a tool writes files, sends a message, or changes state, so declare side effects and pricing in ToolGovernance for each exact name.

Import existing function toolsrust
use std::collections::BTreeMap;
use chio_manifest::{ToolGovernance, ToolManifest};

let governance = BTreeMap::from([(
    "get_weather".to_owned(),
    ToolGovernance { has_side_effects: false, pricing: None },
)]);
let manifest = ToolManifest::from_openai_tools(
    "weather", server_public_key, "1.0.0", &openai_tools, &governance,
)?;

openai_tools is your existing array of function definitions; server_public_key belongs to the tool server your host registers. pricing: None advertises no metering; use Some(ToolPricing) for a priced tool. Importing a schema authorizes no call.

The builder rejects missing or extra declarations, duplicate names, invalid pricing, malformed schema objects, and provider-native tool types. It preserves names, descriptions, parameter schemas, and Responses output schemas. Omitted or null descriptions become empty strings, omitted or null parameters become an empty object schema, and omitted or null output schemas stay absent. Provider options such as strict are not manifest fields. The result is unsigned: your host signs and registers it, and issues the capability used for invocation.

Schema syntax is checked against its declared built-in JSON Schema draft, or draft 2020-12 when $schema is absent, and custom meta-schemas are refused. External $ref values are kept without being fetched or checked; your host resolves references and validates runtime arguments and outputs. The manifest type derives no Default, so every field is named, server_tools included; fields are optional only with Option or a serde default.

crates/platform/chio-manifest/src/lib.rs30-63rust
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 the agent is governable with no tool spec rewrite. Each tool's input_schema must be a JSON object.

Native transport and streaming

ChioOpenAiAdapter and its extract, execute, and convert helpers are always compiled. The provider-adapter feature adds an API for agents that forward native requests through Chio or stream tool calls over SSE, and pulls in chio-tool-call-fabric and chio-provider-adapter-core.

Cargo.tomltoml
[dependencies]
chio-core = { package = "chio-core-types", git = "https://github.com/backbay-labs/chio" }
async-trait = "0.1"

[dependencies.chio-openai-adapter]
git = "https://github.com/backbay-labs/chio"
features = ["provider-adapter"]
  • Lift and lower. OpenAiAdapter implements chio_tool_call_fabric::ProviderAdapter (lift, lower, and lift_batch), lifting Responses API function_call items into the shared ToolInvocation and lowering a kernel verdict into OpenAI tool_outputs JSON.
  • SSE verdict gating. gate_sse_stream and GatedSseStream buffer a streamed tool-call block and release it only after its verdict allows; a denied call never surfaces mid-stream.
  • Outbound HTTP. Native /v1/responses and /v1/chat/completions requests forward to the OpenAI API over the shared chio-provider-adapter-core transport, with a mock transport for hermetic tests.

Every provider-adapter entry point checks the configured api_version against OPENAI_RESPONSES_API_VERSION = "responses.2026-04-25" and refuses a mismatch before the kernel. Set it to that value, or leave it unset to take the default. Govern Any Provider's Tool Calls covers the shared fabric.

Symptoms and fixes

  • Extraction returns an error. Both extractors return Result. A malformed tool_calls entry is an error; folding it into an empty list would skip the call.
  • Responses rejects the tool schema. The JSON helper emits the nested Chat Completions format. Use the flat Responses mapping, and strict mode only with compatible schemas.
  • A ToolManifest literal does not compile. It derives no Default, so name every field, server_tools included.
  • Every call denies with SyncBridgeIncompatibleWithCurrentThreadRuntime. The execute path bridges 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 left out of the conversion helpers, so its tool_call_id is unanswered. Complete its nonce-aware retry before continuing, and never present preflight content as executed output.
  • An enabled guard never denies. The function name does not classify into that guard's action class. Guards are chosen by tool name, not argument content.
  • A provider-adapter entry point refuses before the kernel. The configured api_version differs from the pinned Responses snapshot. Set it to the pinned value or leave it unset.
  • A deny receipt's decision.guard is always kernel. The guard that fired is in evidence[].guard_name.

Next: add another provider