BuildAgent SDKs
LangChain and Provider SDKs
Use LangChain, Anthropic, or OpenAI-compatible tool loops with Chio authorization and signed receipts.
Prerequisites
309 Docker quickstart running locally (the hosted edge on port 8931 and the trust service on port 8940), or the equivalent direct chio trust serve plus chio mcp serve-http processes. The default bearer token is demo-token. See the Installation guide if you have not set up the stack yet.What it shows
These examples show how agents can keep their provider SDK while routing tool calls through Chio.
- LangChain wraps a Chio tool as a
StructuredTool. The LangChain runtime calls the tool the way it would call any local function; the function body forwards through the hosted Chio session. - Anthropic SDK maps the hosted tool inventory into Anthropic
toolsentries, runs the standardtool_use/tool_resultloop, and dispatches eachtool_useback through the session. - OpenAI-compatible maps the same inventory into
type: "function"entries for any Chat Completions endpoint (OpenAI, Azure OpenAI, or any OpenAI-compatible provider) and dispatches eachtool_callsentry through the session.
Receipts use the same structure regardless of which SDK produced the call. Tool name, parameters, capability ID, decision, and signature use the same chio.receipt.v1. That is the property the cross-provider policy example uses to compare provider fixtures.
How the kernel fits in
The provider SDK still owns the model loop. What changes is the moment between the provider returning a tool selection and the tool actually running. In these examples that step is replaced by a call on the hosted Chio session:
- The session was opened against the hosted edge with a bearer token, which gave it an active capability id (resolved through
/admin/sessions/<id>/trust). - Each invocation goes through
session.callTool( Node) orsession.call_tool( Python). The hosted edge runs the kernel pipeline: capability validation, guard evaluation, receipt signing. - Tool output flows back to the SDK in whatever shape it expects: plain text for LangChain, a
tool_resultfor Anthropic, arole: "tool"message for OpenAI. - The example then queries
ReceiptQueryClientagainst the trust service to retrieve the receipt ID and decision for the call. This confirms that the service returned a receipt for the example call.
LangChain
Project root: examples/langchain.
Install and Run
cd examples/langchain
python3 -m venv .venv
source .venv/bin/activate
pip install -e ../../sdks/python/chio-py -e .
python run.pyDefault endpoints are CHIO_BASE_URL=http://127.0.0.1:8931, CHIO_CONTROL_URL=http://127.0.0.1:8940, CHIO_AUTH_TOKEN=demo-token. Override the demo input with CHIO_MESSAGE. The script prints a JSON summary with the hosted session id, capability id, tool inventory, echoed payload, and receipt id.
StructuredTool Wrapping
The example exposes the Chio-governed echo_text tool as a LangChain StructuredTool. The body of the tool function is a single hosted-session call; the args_schema is a Pydantic model that LangChain validates before invocation.
def echo_via_chio(message: str) -> str:
"""Call the Chio-governed echo_text tool and return its text payload."""
result = session.call_tool("echo_text", {"message": message}).get("result", {})
if result.get("structuredContent"):
return str(result["structuredContent"].get("echo", ""))
content = result.get("content", [])
return "\n".join(
item["text"] for item in content if item.get("type") == "text"
)
tool = StructuredTool.from_function(
func=echo_via_chio,
name="chio_echo_text",
description="Invoke the Chio-governed echo_text MCP tool",
args_schema=EchoInput,
)From LangChain's point of view this is a normal tool: an agent executor invokes it, observes the string return value, and folds that into the next reasoning step. The wrapping happens once at startup; everything else is the LangChain runtime.
Receipt Lookup
After invoking the tool the example queries the trust service for the receipt produced by that capability. The capability id is pulled from the session's admin trust endpoint:
capability_id = session_capability_id(base_url, auth_token, session.session_id)
# ... LangChain runs, the governed tool is called once ...
receipts = ReceiptQueryClient(control_url, auth_token).query(
{"capabilityId": capability_id, "limit": 10}
)
receipt = receipts.get("receipts", [])[-1]
print(json.dumps({
"sessionId": session.session_id,
"capabilityId": capability_id,
"echo": result,
"receiptId": receipt.get("id"),
"receiptDecision": receipt.get("decision"),
}, indent=2))Cross-link: LangGraph integration for the graph-runtime equivalent of this pattern.
Anthropic SDK
Project root: examples/anthropic-sdk.
Install and Run
cd examples/anthropic-sdk
npm --prefix ../../sdks/typescript/chio-ts ci
npm --prefix ../../sdks/typescript/chio-ts run build
npm installThe example has two modes. Use --dry-run to exercise the Chio path without an Anthropic key (it initializes the session, lists tools, calls echo_text, and resolves a receipt). Use a live Claude run when you have a key.
# Offline verification, no API key required.
node run.mjs --dry-run
# Live run.
ANTHROPIC_API_KEY=... node run.mjs "Use the echo_text tool to say hello from Claude."Defaults to claude-sonnet-4-20250514; override with ANTHROPIC_MODEL.
Mapping Hosted Tools to Anthropic Tools
The mapping is mechanical. Hosted-edge tool entries already carry a name, description, and JSON Schema input shape; the example translates each into the field names Anthropic expects.
function anthropicToolsFromMcp(tools) {
return tools.map((tool) => ({
name: tool.name,
description: tool.description ?? "",
input_schema: tool.inputSchema ?? { type: "object", properties: {} },
}));
}The tool_use Loop
The agent loop follows Anthropic's standard shape. The governed call is session.callTool call that replaces what would otherwise be a direct dispatch.
while (true) {
const response = await anthropic.messages.create({
model, max_tokens: 512, messages, tools: anthropicTools,
});
messages.push({ role: "assistant", content: normalizeAssistantContent(response.content) });
const toolUses = response.content.filter((block) => block.type === "tool_use");
if (toolUses.length === 0) {
const receipt = await latestReceipt(controlUrl, authToken, capabilityId);
// ... print summary, exit ...
return;
}
const toolResults = [];
for (const toolUse of toolUses) {
const result = await session.callTool(toolUse.name, toolUse.input ?? {});
toolResults.push({
type: "tool_result",
tool_use_id: toolUse.id,
content: renderToolResult(result),
});
}
messages.push({ role: "user", content: toolResults });
}Each session.callTool call produces one signed receipt. The receipt is fetched at the end of the loop through latestReceipt, which pulls the most recent entry for the session's capability id.
OpenAI-Compatible
Project root: examples/openai-compatible.
Install and Run
cd examples/openai-compatible
npm --prefix ../../sdks/typescript/chio-ts ci
npm --prefix ../../sdks/typescript/chio-ts run build
npm installUse --dry-run without an API key, or supply OPENAI_API_KEY for a live run. Override the base URL with OPENAI_BASE_URL to point at any OpenAI-compatible provider; override the model with OPENAI_MODEL (default gpt-5-mini).
node run.mjs --dry-run
OPENAI_API_KEY=... node run.mjs "Use the echo_text function to say hello from GPT."Mapping to OpenAI Function Tools
OpenAI tool entries nest the function shape under a function key with parameters instead of input_schema. The hosted manifest maps directly onto that shape.
function openAiToolsFromChio(tools) {
return tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description ?? "",
parameters: tool.inputSchema ?? { type: "object", properties: {} },
},
}));
}The tool_calls Loop
Tool arguments arrive as a JSON-encoded string on tool_call.function.arguments. The example parses, dispatches through the session, and feeds each result back as a role: "tool" message keyed on the tool_call_id.
for (const toolCall of toolCalls) {
if (toolCall.type !== "function") continue;
const args =
toolCall.function.arguments && toolCall.function.arguments.trim()
? JSON.parse(toolCall.function.arguments)
: {};
const result = await session.callTool(toolCall.function.name, args);
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: renderToolResult(result),
});
}For the in-process Rust adapter that intercepts at the same point without a hosted session in between, see Govern OpenAI Tool Calls.
Receipt Structure
All three examples print a JSON summary that includes receiptId and receiptDecision. The underlying receipt body is the same regardless of which SDK produced the tool call. The fields that vary are the model provenance fields the trust service attaches; the kernel-issued verdict, tool name, arguments, and signature are stable.
| SDK | Tool dispatch surface | Hosted call |
|---|---|---|
| LangChain | StructuredTool.from_function | session.call_tool(name, args) |
| Anthropic SDK | tool_use / tool_result | session.callTool(name, input) |
| OpenAI-compatible | tool_calls / role: "tool" | session.callTool(name, args) |
The hosted edge is the trust boundary
Are the Receipts Identical?
Run all three examples against the same hosted edge, save the printed JSON to disk, then diff the receipt-bearing fields after sorting keys. The provider provenance differs (different model names, different SDK strings, different request ids); the kernel-issued verdict, tool name, arguments, capability id, and signature are stable.
# Capture from each SDK example.
python run.py > receipt-langchain.json
node run.mjs --dry-run > receipt-anthropic.json
node run.mjs --dry-run > receipt-openai.json
# Diff after sorting keys.
diff <(jq -S '.' receipt-langchain.json) <(jq -S '.' receipt-anthropic.json)
diff <(jq -S '.' receipt-anthropic.json) <(jq -S '.' receipt-openai.json)Expected output: the four bookkeeping fields differ (sessionId, capabilityId, receiptId, and the SDK echo payload string). The toolNames list and receiptDecision match byte-for-byte. The kernel-signed receipt body the trust service returns is structurally identical across the three providers; the model, organization ID, and request ID on the call provenance side do not.
< "sessionId": "sess_01HF...langchain",
< "capabilityId": "cap_01HF...lc",
< "echo": "hello from LangChain",
< "receiptId": "rcpt_01HF...lc",
---
> "sessionId": "sess_01HF...anthropic",
> "capabilityId": "cap_01HF...an",
> "echo": "hello from the Anthropic SDK dry-run",
> "receiptId": "rcpt_01HF...an",For an offline byte-for-byte equality proof against fixture captures rather than live runs, see Cross-Provider Policy.
Smoke Assertions
These three examples do not ship dedicated smoke.sh files. The scripts themselves end with a RuntimeError or thrown Error if any of the invariants below break, so a successful exit is the assertion. The invariants are encoded directly in the run scripts:
capability_id = session_capability_id(base_url, auth_token, session.session_id)
# raises if /admin/sessions/<id>/trust returns no active capability id
if not isinstance(capability_id, str) or not capability_id:
raise RuntimeError("session trust endpoint did not return an active capability id")
receipts = ReceiptQueryClient(control_url, auth_token).query(
{"capabilityId": capability_id, "limit": 10}
)
receipt_list = receipts.get("receipts", [])
if not receipt_list:
raise RuntimeError("receipt query did not return the governed tool receipt")async function sessionCapabilityId(baseUrl, authToken, sessionId) {
const response = await fetch(`${baseUrl}/admin/sessions/${sessionId}/trust`, {
headers: { Authorization: `Bearer ${authToken}` },
});
if (!response.ok) {
throw new Error(`session trust query failed with HTTP ${response.status}`);
}
const payload = await response.json();
const capabilityId = payload.capabilities?.[0]?.capabilityId;
if (!capabilityId) {
throw new Error("session trust endpoint did not return an active capability id");
}
return capabilityId;
}
async function latestReceipt(controlUrl, authToken, capabilityId) {
const receipts = await new ReceiptQueryClient(controlUrl, authToken).query({
capabilityId, limit: 10,
});
const receipt = receipts.receipts?.at(-1);
if (!receipt) {
throw new Error("receipt query did not return the governed tool receipt");
}
return receipt;
}Each script asserts three things: the hosted session has an active capability id (HTTP 200 plus a non-empty capabilities[0].capabilityId), each governed call produces a receipt, and the trust service returns at least one receipt entry for the capability id. Process exit code 0 means all three held.
Inspect After
After a run, use the printed JSON to find the capability ID. Pipe it through jq for the capability id, then query the trust service to pull the signed receipt bodies for that capability. The lookup endpoint is /v1/receipts/query (the same endpoint ReceiptQueryClient uses); there is no per-id fetch route.
# Run and capture.
python run.py > out.json
# Pull the capability id from the printed summary.
CAPABILITY_ID=$(jq -r '.capabilityId' out.json)
echo "$CAPABILITY_ID"
# cap_01HF...
# Fetch the signed receipt(s) for that capability from the trust service.
curl -sH "Authorization: Bearer demo-token" \
"http://127.0.0.1:8940/v1/receipts/query?capabilityId=$CAPABILITY_ID" | jq .{
"totalCount": 1,
"receipts": [
{
"id": "rcpt_01HF...",
"timestamp": 1700000000,
"capability_id": "cap_01HF...",
"tool_server": "wrapped-http-mock",
"tool_name": "echo_text",
"action": {
"parameters": { "message": "hello from LangChain" },
"parameter_hash": "abc123..."
},
"decision": { "verdict": "allow" },
"content_hash": "deadbeef...",
"policy_hash": "cafebabe...",
"kernel_key": "aa...",
"signature": "bb..."
}
]
}The verdict is nested under decision.verdict (lower-case allow or deny), the arguments live at action.parameters, and the server is tool_server. To cross-check against the trust service SQLite store directly, the tool receipts land in the chio_tool_receipts table:
sqlite3 ./state/trust-receipts.sqlite3 \
"SELECT receipt_id, decision_kind, tool_name FROM chio_tool_receipts ORDER BY seq DESC LIMIT 5;"
# rcpt_01HF...|allow|echo_text
# rcpt_01HE...|allow|echo_textWhen to Use Each SDK Example
Decision rule
Use LangChain when you already wrap tools as StructuredTool entries and want LangChain agents, chains, or graphs to call governed tools without changing their dispatch loop. The Pydantic args_schema gives you input validation before the kernel even sees the call.
Use the Anthropic SDK example when you run a Claude tool_use / tool_result loop and want each dispatch to reach the hosted session. The example maps the hosted manifest into Anthropic's input_schema field shape.
Use the OpenAI-compatible example when your client targets Chat Completions tool_calls / role: "tool" messages: OpenAI, Azure OpenAI, or any provider that ships an OpenAI-compatible endpoint (override OPENAI_BASE_URL).
Don't use these examples if you want a one-process Rust adapter with no hosted edge in between (use the in-process variant in Govern OpenAI Tool Calls), you want to wrap an existing A2A or ACP agent (see Hello A2A and Hello ACP), or you want to verify cross-provider policy equality offline (see Cross-Provider Policy).
Framework Bindings Beyond These Examples
The three examples above wire LangChain and two provider SDKs by hand. Chio also ships prebuilt bindings for the common agent, workflow, and data frameworks. Each is a standalone package under sdks/python/, installable with pip install chio-<name> (or uv pip install), and issues the same signed receipts as these examples. These bindings do not yet have dedicated example directories.
| Package | What it wraps |
|---|---|
chio-autogen | AutoGen register_function, function_map, and GroupChat. |
chio-crewai | CrewAI crewai.tools.BaseTool. |
chio-langgraph | LangGraph state-graph nodes; bridges the graph's interrupt() human-in-the-loop pause to a pending-approval verdict. |
chio-llamaindex | LlamaIndex FunctionTool and QueryEngineTool. |
chio-temporal | Temporal Activities; denials raise a non-retryable ApplicationError, and each run emits an aggregate WorkflowReceipt. |
chio-prefect | Prefect @task / @flow; denials raise PermissionError and post Prefect Events to the flow-run timeline. |
chio-dagster | Dagster @asset / @op; denials raise PermissionError and verdicts attach as MetadataValue on the materialization. |
chio-airflow | Airflow BaseOperator and TaskFlow; denials raise AirflowException and receipt ids are pushed to XCom. |
chio-ray | Ray ray.remote tasks and actor methods. |
chio-hermes | Hermes plugin; zero-code through chio mcp serve, or installed directly. |
chio-code-agent | Zero-config wrappers for coding agents (Claude Code, Cursor, any MCP agent). |
chio-iac | Terraform and Pulumi under a two-phase capability model: low-privilege plan, high-privilege apply. |
chio-observability | LangSmith and LangFuse span bridges. |
chio-streaming | Event-bus middleware for Kafka, NATS JetStream, Pulsar, AWS EventBridge, GCP Pub/Sub, Redis Streams, and Flink. |
Next Steps
- Govern OpenAI Tool Calls · the in-process Rust adapter for OpenAI clients
- LangGraph integration · graph-runtime variant of the LangChain pattern
- Cross-Provider Policy · proving one policy applies across all three providers