LangChain and Provider SDKs
Three example projects show how Chio sits underneath the SDK you already use. The provider keeps choosing tools, your existing client code keeps dispatching them, and Chio mediates every dispatch into a capability check, a guard pipeline, and a signed receipt. The examples live at examples/langchain, examples/anthropic-sdk, and examples/openai-compatible. All three share the same hosted-edge model: a hosted Chio session owned by the SDK ( chio for Python, @chio-protocol/sdk for Node), tool inventory pulled from listTools, and a receipt resolved from the trust service after every governed call.
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
All three examples answer the same question from a different SDK angle: how does an agent that already talks to a provider keep using that provider while every tool call routes 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.
The shape of every receipt is identical regardless of which SDK produced the call. Tool name, arguments, capability id, decision, and signature all land on the same chio.receipt.v1. That is the property the cross-provider policy example depends on.
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 every example 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 surface the receipt id and decision for the call. This is how each demo proves a receipt was actually minted.
LangChain
Project root: examples/langchain.
Install and Run
cd examples/langchain
python3 -m venv .venv
source .venv/bin/activate
pip install -e ../../packages/sdk/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 ../../packages/sdk/chio-ts ci
npm --prefix ../../packages/sdk/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 is the standard Anthropic shape. The only governed line is the 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 });
}Every session.callTool 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 ../../packages/sdk/chio-ts ci
npm --prefix ../../packages/sdk/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 rather than 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.
The Receipt Each SDK Produces
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: only 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, organisation 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), every governed tool 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, the printed JSON has everything you need. Pipe it through jq for the receipt id, then query the trust service directly to pull the full signed receipt body.
# Run and capture.
python run.py > out.json
# Pull the receipt id from the printed summary.
RECEIPT_ID=$(jq -r '.receiptId' out.json)
echo "$RECEIPT_ID"
# rcpt_01HF...
# Fetch the signed receipt body from the trust service.
curl -sH "Authorization: Bearer demo-token" \
"http://127.0.0.1:8940/v1/receipts/$RECEIPT_ID" | jq .{
"id": "rcpt_01HF...",
"schema": "chio.receipt.v1",
"decision": "allow",
"capabilityId": "cap_01HF...",
"toolName": "echo_text",
"serverId": "hello-tool-srv",
"arguments": { "message": "hello from LangChain" },
"signature": "ed25519:..."
}Cross-check the receipt against the local trust service SQLite store directly:
sqlite3 ./state/trust-receipts.sqlite3 \
"SELECT id, decision, tool_name FROM receipts ORDER BY id 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 every dispatch to land on 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).
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